diff --git a/docs/README.md b/docs/README.md index 8492cccb9..0d4c24053 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,7 @@ ## 后端、运维与测试 - [BgFilter 受限资源调度方案](./technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md) +- [Issue225 登录成功 AGC 用户归属修复](./technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md):登录 route tracking 的真实用户归属、`daily_login` 幂等边界和实施验收。 - [SpacetimeDB 连接池取消安全](./【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md) - [Jenkins 容器预览部署控制面](./technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md) - [浏览器内 AI Web 工程沙箱预览](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ff1b03c2f..fcc000181 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,15 @@ - 关联文档:相关 PRD、技术文档、提交或 Issue ``` +## 2026-09-03 AGC 登录 route event 使用 handler 已验证主体归属 + +- 背景:登录请求进入时尚未拥有 `AuthenticatedAccessToken`,通用 tracking middleware 无法从响应 extensions 归属登录成功用户;将 AGC marker 直接写入按用户/业务日幂等的 `daily_login` 又会受到不同来源登录顺序影响。 +- 决策:密码登录和手机号登录 handler 在认证及 session 创建成功后,仅对合法 AGC marker 请求向响应 extensions 附加一次性 `TrackingLoginSubject`。tracking middleware 在现有 `ExternalApiPrincipal`、`AuthenticatedAccessToken` 之后使用该主体生成登录 route event 的 `user_id`、`owner_user_id` 和 User scope。`daily_login` 保持原有 event key、幂等键、业务日和 metadata 语义,不承担 AGC 来源归因。 +- 安全边界:主体只来自后端认证服务返回的用户 ID;Header 仅决定是否进行 AGC 来源归因,不参与身份计算;不解析、不记录 access token、refresh token 或 Cookie。 +- 影响范围:`api-server` 登录 handler、tracking middleware、Issue225 技术方案和定向集成测试;不修改 SpacetimeDB schema、migration、bindings、OpenAPI、后台页面或认证响应协议。 +- 验证方式:密码/手机号登录真实 `build_router` 链路分别验证 AGC route event 的 marker、真实用户归属和 outbox 落盘;tracking identity 单测、`cargo check --locked -p api-server`、`cargo test --locked -p api-server agc_`、格式、编码和 diff 检查通过。 +- 关联文档:`docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md`、Issue #225、`server-rs/crates/api-server/src/tracking.rs`、`server-rs/crates/api-server/src/app.rs`。 + ## 2026-09-02 GDD 审批卡的后台 hydrate 不抢占已加载决定 - 背景:项目页首次加载和运行态刷新可能并发 hydrate。卡片已经显示后,短暂的 `hydrateBusy` 会让已打开的评论弹层提交按钮瞬时变灰,用户无法提交已输入的修改意见。 diff --git a/docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md b/docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md new file mode 100644 index 000000000..39188559c --- /dev/null +++ b/docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md @@ -0,0 +1,305 @@ +# Issue225 登录成功 AGC 用户归属修复方案 + +状态:已实现(2026-09-03) + +## 1. 一句话交付目标 + +对带有合法 `X-Genarrative-Client: agc` 的登录成功请求,将现有登录 route tracking event 归属到登录成功后的真实用户;保持 `daily_login` 原有的“用户 + 北京时间业务日”幂等语义,不解析 access token,不新增数据库表、事件体系或状态机。 + +## 2. Issue 边界 + +### 2.1 本次只做 #225 + +本方案只修改主站 `api-server` 的登录成功埋点归属链路。 + +`#226` 已冻结并合入的客户端 Header 注入、主站 client factory、同源重定向和第三方请求边界作为本方案输入,不在本次修改。 + +### 2.2 本次解决 + +- AGC 密码登录成功 route event 的真实用户归属。 +- AGC 手机号登录成功 route event 的真实用户归属。 +- tracking middleware 与登录 handler 之间的可信主体传递。 +- 方案文档、测试和验收口径与实际登录阶段保持一致。 + +### 2.3 明确不做 + +- 不修改 access token、refresh token、Cookie、登录响应体或认证协议。 +- 不从响应体、Cookie 或 Header 解析用户身份。 +- 不把 `X-Genarrative-Client` 当作认证或授权边界。 +- 不修改 `daily_login` 的 event key、幂等键、业务日算法或 daily stat 语义。 +- 不新增 `agc_login_success` 平行事件。 +- 不新增 SpacetimeDB 表、字段、migration、binding 或后台页面。 +- 不改变未标记请求的既有 route tracking 统计口径。 +- 不在 `RequestContext` 中增加一次性登录主体状态。 + +## 3. 修复前行为与根因 + +当前登录链路大致如下: + +```text +请求进入 + → tracking middleware 读取 AGC marker + → 登录 handler 校验密码/验证码 + → 创建 session 和 access token + → handler 手工记录 daily_login + → 返回 access token / Cookie + → tracking middleware 读取 response extensions + → 写入登录 route event +``` + +当前 `tracking` middleware 只从 response extensions 读取: + +- `AuthenticatedAccessToken`; +- `ExternalApiPrincipal`。 + +登录请求进入时还没有 `AuthenticatedAccessToken`。虽然 +`phone_auth.rs` 和 `password_entry.rs` 的 handler 在认证成功后已经拥有可信的 +`result.user.id`,但目前没有把这个主体传给 tracking middleware。 + +因此会出现: + +- `auth_password_login_success` / `auth_phone_login_success` route event 带有 `client=agc`,但 `user_id`、`owner_user_id` 为空,`scope_id` 为 `anonymous`; +- `daily_login` 有真实用户 ID,但没有 AGC 来源信息。 + +这不是认证失败或用户身份校验错误,而是“认证成功发生在 handler 内部,通用 middleware 无法看到 handler 内部认证结果”的传递缺口。 + +## 4. 设计决策 + +### 4.1 route event 作为 AGC 登录来源的权威记录 + +本次不把 `daily_login` 改造成 AGC 来源事件,而是让现有登录 route event 同时承载: + +- 本次成功登录请求的 route、method、status; +- `client=agc` 来源标记; +- 认证成功后的真实用户归属。 + +这样可以直接回答“哪些用户通过 AGC 成功登录”,并且保留现有 route event 的每请求事件语义。 + +### 4.2 `daily_login` 保持原有语义 + +`daily_login` 使用按用户和业务日生成的稳定 event id: + +```text +daily-login:{user_id}:{beijing_day_key} +``` + +同一用户一天内的第二次登录会被幂等去重。如果把 `client=agc` 写进该事件,会出现来源丢失或误标记: + +- 先网页登录、后 AGC 登录:AGC 来源可能被第二次幂等跳过; +- 先 AGC 登录、后网页登录:当天事件会永久看起来像 AGC 登录; +- 同一天不同来源无法表达多次登录事实。 + +因此 `daily_login` 继续只表示“该用户当天已完成登录”,不承担 AGC 来源归因。需要按用户查询 AGC 登录时,查询带 `metadata.client = "agc"` 的登录 route event。 + +### 4.3 使用一次性 response extension,不引入状态机 + +增加一个仅在本次 HTTP 请求内存在的内部主体类型,例如: + +```rust +TrackingLoginSubject { + user_id: String, +} +``` + +它只保存认证服务已经确认的用户 ID,不保存 token,不落库,不跨请求复用,也不引入新的生命周期状态。 + +## 5. 目标链路 + +```text +AGC marker + → tracking middleware 解析并放入 request extensions + → 登录 handler 完成密码/验证码校验 + → 从 result.user.id 构造 TrackingLoginSubject + → 将主体放入 response extensions + → tracking middleware 读取主体 + → 现有 identity resolver 生成 user/owner/scope + → 现有 route tracking metadata/outbox/SpacetimeDB 链路 +``` + +## 6. 具体实现约定 + +### 6.1 登录 handler + +涉及: + +- `server-rs/crates/api-server/src/phone_auth.rs` +- `server-rs/crates/api-server/src/password_entry.rs` + +处理顺序: + +1. 保持现有密码/验证码校验、session 创建、Cookie 设置和响应 body 不变。 +2. 只有当前请求的 marker 精确解析为 `TrackingClientMarker::Agc` 时,才附加 `TrackingLoginSubject`。 +3. `user_id` 只能来自认证服务返回的 `result.user.id`。 +4. 仅在认证和 session 创建成功后附加主体;任何失败路径都不附加。 +5. 通过响应转换 helper 写入 response extensions,不能改变 HTTP status、headers 或 JSON body。 + +Header 只参与“是否进行 AGC 来源归因”的判断,不能提供用户身份。即使调用方伪造 Header,最终写入的用户 ID 仍来自后端已验证的登录结果。 + +### 6.2 tracking middleware + +涉及: + +- `server-rs/crates/api-server/src/app.rs` +- `server-rs/crates/api-server/src/tracking.rs` + +在现有主体来源之外增加登录主体兜底: + +```text +ExternalApiPrincipal + → AuthenticatedAccessToken + → TrackingLoginSubject +``` + +其中: + +- `ExternalApiPrincipal` 和 `AuthenticatedAccessToken` 的现有优先级和行为不变; +- `TrackingLoginSubject` 只用于合法 AGC marker 的登录 route; +- `TrackingLoginSubject` 的 `user_id` 和 `owner_user_id` 由同一个已验证用户 ID 填充; +- User scope 的 `scope_id` 解析为真实用户,不再回退到 `anonymous`。 + +### 6.3 route 策略边界 + +当前分支的 route policy 必须保持: + +- `/api/auth/entry` 使用 AGC-only route spec;没有合法 marker 时不产生 route event,但登录业务本身不被拒绝; +- `/api/auth/phone/login` 继续使用既有全客户端 route tracking 语义;带合法 marker 时新增真实用户归属,未标记时保持既有匿名 route event 语义。 + +本方案不把手机号登录改造成 AGC-only,也不扩大 #225 的 route coverage。 + +## 7. 事件结果示例 + +### 7.1 AGC 密码登录成功 + +```text +event_key = auth_password_login_success +metadata.client = agc +user_id = user-xxx +owner_user_id = user-xxx +scope_kind = user +scope_id = user-xxx +``` + +### 7.2 AGC 手机号登录成功 + +```text +event_key = auth_phone_login_success +metadata.client = agc +user_id = user-xxx +owner_user_id = user-xxx +scope_kind = user +scope_id = user-xxx +``` + +### 7.3 `daily_login` + +```text +event_key = daily_login +user_id = user-xxx +event_id = daily-login:user-xxx:{beijing_day_key} +metadata = 保持现有 operation/loginMethod 等字段,不承担 AGC 来源归因 +``` + +## 8. 不采用的方案 + +### 8.1 只给 `daily_login` 追加 `client` + +不采用。它会受到用户当天首次登录来源和幂等去重顺序影响,不能准确表达 AGC 登录事实。 + +### 8.2 从响应体或 Cookie 解析 access token + +不采用。会让埋点 middleware 依赖认证响应格式,扩大敏感凭据处理边界,且没有必要。 + +### 8.3 新增 `agc_login_success` 事件 + +不采用。现有登录 route event 已经有 route、method、status、event key 和 metadata 承载能力,再增加事件会产生第二套统计口径和后台查询约定。 + +### 8.4 让 Header 直接决定 user_id + +不采用。Header 是可伪造的来源标签,不能成为身份事实。 + +### 8.5 修改已有 `daily_login` 事件的重复记录行为 + +不采用。这样会改变每日任务、daily stat 和事件幂等语义,超出本次登录来源归因问题。 + +## 9. 测试与验收 + +### 9.1 middleware 成功链路 + +通过现有 `build_router` 和隔离 tracking outbox,验证: + +```text +HTTP request + → marker middleware + → 登录 handler + → response extension + → tracking middleware + → outbox +``` + +断言: + +- 登录响应 status、headers、body 与原行为一致; +- AGC 密码登录 route event 有 `metadata.client = "agc"`; +- AGC 手机登录 route event 有 `metadata.client = "agc"`; +- `user_id`、`owner_user_id`、`scope_id` 为真实用户; +- outbox metadata 不包含 access token、refresh token、Cookie、密码或验证码。 + +### 9.2 未标记回归 + +- 未标记手机号登录继续保持既有 route tracking 语义; +- 未标记密码登录不产生 AGC route event; +- `/api/auth/entry` 缺少 marker 时仍不放行 route tracking,但不改变登录业务响应; +- Header 伪造不会改变最终 user_id。 + +### 9.3 `daily_login` 回归 + +- `daily_login` 仍使用真实用户; +- event id 仍按用户 + 北京时间业务日生成; +- 同一用户同一天重复登录仍幂等; +- 本次修复不要求从 `daily_login` 查询 AGC 来源。 + +### 9.4 常规验证 + +按修改范围运行: + +- api-server 相关定向 Rust 测试; +- `cargo check --locked --manifest-path server-rs/Cargo.toml -p api-server`; +- `cargo fmt --check`; +- `npm run check:encoding`; +- `git diff --check`。 + +CI 已覆盖的全量测试不要求本地重复运行。 + +## 10. 数据和发布边界 + +- 不做历史数据回填。旧登录 route event 没有足够可信的信息安全补写用户归属。 +- 不修改 tracking schema、migration、SpacetimeDB bindings 或后台 readback API。 +- 新代码上线后产生的 AGC 登录 route event 才保证用户归属完整。 +- 认证主链路、登录响应和埋点失败不阻断业务的既有语义全部保留。 + +## 11. 实施顺序 + +1. 在 `tracking.rs` 增加内部 `TrackingLoginSubject` 和身份解析兜底。 +2. 在密码/手机号登录成功响应中附加主体 extension。 +3. 在 `app.rs` tracking middleware 读取主体并传入 route tracking。 +4. 增加真实 middleware 链路及未标记回归测试。 +5. 更新 Issue225 现有方案和阶段验收文档中的登录归属表述。 +6. 完成定向验证后再提交代码。 + +## 12. 最终判断 + +推荐使用“登录 handler 传递一次性可信主体,route event 负责 AGC 登录归因,`daily_login` 保持原语义”的方案。 + +该方案只增加一个单字段、请求级的内部传递对象,不引入新的持久化状态或状态机;同时避免了把每日幂等事件错误地当作来源审计事件,最终代码边界和后台查询口径都更容易维护。 + +## 13. 实现记录 + +本方案已在当前分支落地: + +- `server-rs/crates/api-server/src/tracking.rs` 增加 `TrackingLoginSubject`,并将其作为登录主体的最后兜底来源;既有 `ExternalApiPrincipal`、`AuthenticatedAccessToken` 优先级保持不变。 +- `server-rs/crates/api-server/src/password_entry.rs` 和 `server-rs/crates/api-server/src/phone_auth.rs` 在认证与 session 创建成功后,仅对合法 AGC marker 请求向 response extensions 附加真实用户主体。 +- `server-rs/crates/api-server/src/app.rs` 只在合法 AGC marker 存在时读取该主体并交给 route tracking;未标记请求不使用该新主体来源。 +- `daily_login` helper、event id 幂等、认证响应和 Cookie 语义未修改。 +- 已增加密码登录、手机号登录和主体解析测试;两条真实 `build_router → 登录 handler → response extension → tracking middleware → outbox` 链路均验证通过。 + +本次实现没有修改 SpacetimeDB schema、migration、bindings、OpenAPI 或后台页面,也没有新增事件 key。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index c15a3daab..d898bdbf9 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -25,7 +25,10 @@ use crate::{ response_headers::propagate_request_id_header, state::{AppState, BackpressureState}, telemetry::record_http_observability, - tracking::{record_route_tracking_event_after_success, resolve_tracking_client_marker}, + tracking::{ + TrackingClientMarker, TrackingLoginSubject, record_route_tracking_event_after_success, + resolve_tracking_client_marker, + }, wechat::pay::{ handle_wechat_pay_notify, handle_wechat_pay_refund_notify, handle_wechat_virtual_payment_message_push_verify, handle_wechat_virtual_payment_notify, @@ -253,6 +256,11 @@ async fn record_api_tracking_after_success( .get::() .cloned(); let external_principal = response.extensions().get::().cloned(); + let login_subject = if matches!(client_marker, Some(TrackingClientMarker::Agc)) { + response.extensions().get::().cloned() + } else { + None + }; record_route_tracking_event_after_success( &state, &request_context, @@ -261,6 +269,7 @@ async fn record_api_tracking_after_success( response.status(), authenticated.as_ref(), external_principal.as_ref(), + login_subject.as_ref(), client_marker, ) .await; @@ -269,6 +278,8 @@ async fn record_api_tracking_after_success( #[cfg(test)] mod tests { + use std::path::Path; + use axum::{ Router, body::Body, @@ -428,6 +439,23 @@ mod tests { .expect("password login request should succeed") } + async fn read_tracking_outbox_events(outbox_dir: &Path) -> Vec { + tokio::fs::read_to_string(outbox_dir.join("active.ndjson")) + .await + .expect("tracking middleware should enqueue outbox events") + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("outbox line should be JSON")) + .collect() + } + + fn find_tracking_event<'a>(records: &'a [Value], event_key: &str) -> &'a Value { + records + .iter() + .find(|record| record["event"]["event_key"] == event_key) + .unwrap_or_else(|| panic!("tracking event should exist: {event_key}")) + } + #[tokio::test] async fn showcase_invalid_bearer_is_not_downgraded_and_is_not_cacheable() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -537,6 +565,186 @@ mod tests { let _ = std::fs::remove_dir_all(outbox_dir); } + #[tokio::test] + async fn agc_password_login_tracking_uses_verified_login_user_subject() { + let outbox_dir = std::env::temp_dir().join(format!( + "genarrative-api-agc-password-login-tracking-{}", + OffsetDateTime::now_utc().unix_timestamp_nanos() + )); + let _ = std::fs::remove_dir_all(&outbox_dir); + let config = AppConfig { + tracking_outbox_dir: outbox_dir.clone(), + tracking_outbox_batch_size: 100, + ..AppConfig::default() + }; + let state = AppState::new(config).expect("state should build"); + let user = seed_phone_user_with_password(&state, "13800138002", TEST_PASSWORD).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .header("x-genarrative-client", "agc") + .body(Body::from( + serde_json::json!({ + "purePhoneNumber": "13800138002", + "password": TEST_PASSWORD + }) + .to_string(), + )) + .expect("password login request should build"), + ) + .await + .expect("password login request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let records = read_tracking_outbox_events(&outbox_dir).await; + let event = find_tracking_event(&records, "auth_password_login_success"); + assert_eq!(event["event"]["user_id"], user.id); + assert_eq!(event["event"]["owner_user_id"], user.id); + assert_eq!(event["event"]["scope_id"], user.id); + let metadata = serde_json::from_str::( + event["event"]["metadata_json"] + .as_str() + .expect("tracking metadata should be serialized as a string"), + ) + .expect("tracking metadata should be valid JSON"); + assert_eq!(metadata["client"], "agc"); + assert_eq!(metadata["route"], "/api/auth/entry"); + assert_eq!(metadata["status"], 200); + + let _ = std::fs::remove_dir_all(outbox_dir); + } + + #[tokio::test] + async fn agc_password_login_tracking_does_not_record_without_marker() { + let outbox_dir = std::env::temp_dir().join(format!( + "genarrative-api-unmarked-password-login-tracking-{}", + OffsetDateTime::now_utc().unix_timestamp_nanos() + )); + let _ = std::fs::remove_dir_all(&outbox_dir); + let config = AppConfig { + tracking_outbox_dir: outbox_dir.clone(), + tracking_outbox_batch_size: 100, + ..AppConfig::default() + }; + let state = AppState::new(config).expect("state should build"); + let _user = seed_phone_user_with_password(&state, "13800138003", TEST_PASSWORD).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "purePhoneNumber": "13800138003", + "password": TEST_PASSWORD + }) + .to_string(), + )) + .expect("password login request should build"), + ) + .await + .expect("password login request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + assert!(!outbox_dir.join("active.ndjson").exists()); + + let _ = std::fs::remove_dir_all(outbox_dir); + } + + #[tokio::test] + async fn agc_phone_login_tracking_uses_verified_login_user_subject() { + let outbox_dir = std::env::temp_dir().join(format!( + "genarrative-api-agc-phone-login-tracking-{}", + OffsetDateTime::now_utc().unix_timestamp_nanos() + )); + let _ = std::fs::remove_dir_all(&outbox_dir); + let config = AppConfig { + sms_auth_enabled: true, + tracking_outbox_dir: outbox_dir.clone(), + tracking_outbox_batch_size: 100, + ..AppConfig::default() + }; + let state = AppState::new(config).expect("state should build"); + let app = build_router(state); + + let send_code_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "purePhoneNumber": "13800138002", + "scene": "login" + }) + .to_string(), + )) + .expect("send code request should build"), + ) + .await + .expect("send code request should succeed"); + assert_eq!(send_code_response.status(), StatusCode::OK); + + let login_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .header("x-genarrative-client", "agc") + .body(Body::from( + serde_json::json!({ + "purePhoneNumber": "13800138002", + "code": "123456" + }) + .to_string(), + )) + .expect("phone login request should build"), + ) + .await + .expect("phone login request should succeed"); + + assert_eq!(login_response.status(), StatusCode::OK); + let body = login_response + .into_body() + .collect() + .await + .expect("phone login response body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("phone login response should be valid JSON"); + let user_id = payload["user"]["id"] + .as_str() + .expect("phone login response should include user id"); + + let records = read_tracking_outbox_events(&outbox_dir).await; + let event = find_tracking_event(&records, "auth_phone_login_success"); + assert_eq!(event["event"]["user_id"], user_id); + assert_eq!(event["event"]["owner_user_id"], user_id); + assert_eq!(event["event"]["scope_id"], user_id); + let metadata = serde_json::from_str::( + event["event"]["metadata_json"] + .as_str() + .expect("tracking metadata should be serialized as a string"), + ) + .expect("tracking metadata should be valid JSON"); + assert_eq!(metadata["client"], "agc"); + assert_eq!(metadata["route"], "/api/auth/phone/login"); + assert_eq!(metadata["status"], 200); + + let _ = std::fs::remove_dir_all(outbox_dir); + } + #[cfg(any())] fn build_internal_creative_agent_app() -> Router { let mut config = AppConfig::default(); diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index d1acc699c..37ee937a6 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -2,7 +2,7 @@ use axum::{ Json, extract::{Extension, State}, http::{HeaderMap, StatusCode}, - response::IntoResponse, + response::{IntoResponse, Response}, }; use module_auth::{AuthLoginMethod, PasswordEntryError, PasswordEntryInput}; use serde_json::json; @@ -19,14 +19,16 @@ use crate::{ request_context::RequestContext, session_client::resolve_session_client_context, state::AppState, + tracking::{TrackingClientMarker, TrackingLoginSubject}, }; pub async fn password_entry( State(state): State, Extension(request_context): Extension, headers: HeaderMap, + client_marker: Option>, Json(payload): Json, -) -> Result { +) -> Result { state .refresh_auth_store_from_spacetime() .await @@ -73,13 +75,16 @@ pub async fn password_entry( ) .await; + let tracking_login_subject = + client_marker.map(|Extension(_)| TrackingLoginSubject::new(&result.user.id)); + let mut headers = HeaderMap::new(); attach_set_cookie_header( &mut headers, build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?, ); - Ok(( + let mut response = ( headers, json_success_body( Some(&request_context), @@ -88,7 +93,12 @@ pub async fn password_entry( user: map_auth_user_payload(result.user), }, ), - )) + ) + .into_response(); + if let Some(subject) = tracking_login_subject { + response.extensions_mut().insert(subject); + } + Ok(response) } fn map_password_entry_error(error: PasswordEntryError) -> AppError { diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 37e77acec..f4405afe1 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -2,7 +2,7 @@ use axum::{ Json, extract::{Extension, State}, http::{HeaderMap, StatusCode}, - response::IntoResponse, + response::{IntoResponse, Response}, }; use module_auth::{ AuthLoginMethod, PhoneAuthError, PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput, @@ -27,6 +27,7 @@ use crate::{ request_context::RequestContext, session_client::resolve_session_client_context, state::AppState, + tracking::{TrackingClientMarker, TrackingLoginSubject}, }; pub async fn send_phone_code( @@ -131,8 +132,9 @@ pub async fn phone_login( State(state): State, Extension(request_context): Extension, headers: HeaderMap, + client_marker: Option>, Json(payload): Json, -) -> Result { +) -> Result { // 手机号验证码校验通过后,沿用统一会话签发逻辑,确保 refresh cookie 与 JWT 行为一致。 if !state.config.sms_auth_enabled { return Err( @@ -254,13 +256,16 @@ pub async fn phone_login( ) .await; + let tracking_login_subject = + client_marker.map(|Extension(_)| TrackingLoginSubject::new(&result.user.id)); + let mut headers = HeaderMap::new(); attach_set_cookie_header( &mut headers, build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?, ); - Ok(( + let mut response = ( headers, json_success_body( Some(&request_context), @@ -271,7 +276,12 @@ pub async fn phone_login( referral, }, ), - )) + ) + .into_response(); + if let Some(subject) = tracking_login_subject { + response.extensions_mut().insert(subject); + } + Ok(response) } async fn bind_referral_invite_code_on_registration( diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index 53907f157..e7832ca60 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -19,6 +19,26 @@ pub(crate) enum TrackingClientMarker { Agc, } +/// 登录 handler 在认证成功后向外层 tracking middleware 传递的可信用户主体。 +/// +/// 该类型只存在于当前 HTTP 响应的 extensions 中,不保存 token,也不跨请求复用。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TrackingLoginSubject { + user_id: String, +} + +impl TrackingLoginSubject { + pub(crate) fn new(user_id: &str) -> Self { + Self { + user_id: user_id.trim().to_string(), + } + } + + fn user_id(&self) -> &str { + &self.user_id + } +} + pub(crate) fn resolve_tracking_client_marker(headers: &HeaderMap) -> Option { headers .get(AGC_CLIENT_MARKER_HEADER) @@ -138,6 +158,7 @@ pub async fn record_route_tracking_event_after_success( status: StatusCode, authenticated: Option<&AuthenticatedAccessToken>, external_principal: Option<&ExternalApiPrincipal>, + login_subject: Option<&TrackingLoginSubject>, client_marker: Option, ) { let Some(spec) = resolve_route_tracking_spec(method, path) else { @@ -147,7 +168,7 @@ pub async fn record_route_tracking_event_after_success( return; } - let identity = resolve_tracking_identity(authenticated, external_principal); + let identity = resolve_tracking_identity(authenticated, external_principal, login_subject); let scope_id = resolve_tracking_scope_id(&spec, &identity); let mut draft = TrackingEventDraft::new(spec.event_key, spec.module_key); draft.scope_kind = spec.scope_kind; @@ -173,6 +194,7 @@ fn should_record_route_tracking( fn resolve_tracking_identity( authenticated: Option<&AuthenticatedAccessToken>, external_principal: Option<&ExternalApiPrincipal>, + login_subject: Option<&TrackingLoginSubject>, ) -> TrackingIdentity { if let Some(principal) = external_principal { return TrackingIdentity { @@ -181,14 +203,25 @@ fn resolve_tracking_identity( }; } - let Some(authenticated) = authenticated else { - return TrackingIdentity::default(); - }; - let user_id = authenticated.claims().user_id().to_string(); - TrackingIdentity { - user_id: Some(user_id.clone()), - owner_user_id: Some(user_id), + if let Some(authenticated) = authenticated { + let user_id = authenticated.claims().user_id().to_string(); + return TrackingIdentity { + user_id: Some(user_id.clone()), + owner_user_id: Some(user_id), + }; } + + if let Some(subject) = login_subject { + let user_id = subject.user_id().trim(); + if !user_id.is_empty() { + return TrackingIdentity { + user_id: Some(user_id.to_string()), + owner_user_id: Some(user_id.to_string()), + }; + } + } + + TrackingIdentity::default() } fn resolve_tracking_scope_id(spec: &RouteTrackingSpec, identity: &TrackingIdentity) -> String { @@ -1202,10 +1235,11 @@ mod tests { }; use super::{ - TrackingClientMarker, TrackingEventDraft, build_route_tracking_metadata, - build_tracking_event_input, is_route_tracking_excluded, normalize_route_path, - resolve_route_tracking_spec, resolve_tracking_client_marker, resolve_tracking_identity, - resolve_tracking_scope_id, route_spec, should_record_route_tracking, + TrackingClientMarker, TrackingEventDraft, TrackingLoginSubject, + build_route_tracking_metadata, build_tracking_event_input, is_route_tracking_excluded, + normalize_route_path, resolve_route_tracking_spec, resolve_tracking_client_marker, + resolve_tracking_identity, resolve_tracking_scope_id, route_spec, + should_record_route_tracking, }; fn build_test_authenticated(user_id: &str) -> AuthenticatedAccessToken { @@ -1282,7 +1316,7 @@ mod tests { #[test] fn tracking_identity_keeps_authenticated_user_as_owner() { let authenticated = build_test_authenticated("user-225"); - let identity = resolve_tracking_identity(Some(&authenticated), None); + let identity = resolve_tracking_identity(Some(&authenticated), None, None); assert_eq!(identity.user_id.as_deref(), Some("user-225")); assert_eq!(identity.owner_user_id.as_deref(), Some("user-225")); @@ -1315,7 +1349,7 @@ mod tests { fn tracking_identity_uses_external_api_owner_and_does_not_forge_user() { let authenticated = build_test_authenticated("login-user"); let principal = ExternalApiPrincipal::for_test("owner-user", &[]); - let identity = resolve_tracking_identity(Some(&authenticated), Some(&principal)); + let identity = resolve_tracking_identity(Some(&authenticated), Some(&principal), None); assert_eq!(identity.user_id, None); assert_eq!(identity.owner_user_id.as_deref(), Some("owner-user")); @@ -1329,6 +1363,15 @@ mod tests { assert_eq!(resolve_tracking_scope_id(&spec, &identity), "owner-user"); } + #[test] + fn tracking_identity_uses_verified_login_subject_when_no_session_identity_exists() { + let subject = TrackingLoginSubject::new(" user-225 "); + let identity = resolve_tracking_identity(None, None, Some(&subject)); + + assert_eq!(identity.user_id.as_deref(), Some("user-225")); + assert_eq!(identity.owner_user_id.as_deref(), Some("user-225")); + } + #[test] fn route_tracking_metadata_adds_agc_marker_without_dropping_existing_fields() { let spec = route_spec(