diff --git a/src/stores/auth/auth-actors.ts b/src/stores/auth/auth-actors.ts index a249a1bf..efae0378 100644 --- a/src/stores/auth/auth-actors.ts +++ b/src/stores/auth/auth-actors.ts @@ -15,11 +15,16 @@ import type { LoginStatus } from "@/models/auth/login-status"; import { authRepository } from "@/data/repositories/auth_repository"; import type { IAuthRepository } from "@/data/repositories/interfaces"; import { AuthStorage } from "@/data/storage/auth/auth_storage"; +import { UserStorage } from "@/data/storage/user/user_storage"; import { deviceIdentifier } from "@/utils/device_identifier"; +import { fetchFacebookUserData } from "@/utils/facebook-graph"; +import { Logger } from "@/utils/logger"; import { Result } from "@/utils/result"; import { readGuestId } from "./auth-helpers"; +const log = new Logger("AuthActors"); + // 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例 const authRepo: IAuthRepository = authRepository; @@ -126,9 +131,70 @@ export const syncFacebookBackendActor = fromPromise< guestId, }); if (Result.isErr(result)) throw result.error; + + // 业务 token 已到手 —— 顺带用同一个 accessToken 调 Facebook Graph `/me` + // 拉用户资料(id / name / email / picture),把 pictureUrl + Facebook ID + // 写本地。 + // + // 为什么要这一步?后端 LoginResponse.user.avatarUrl 不一定总有(依赖 + // 后端在 OAuth 流程里有没有抓过);前端用 accessToken 直接调 Graph + // 拿到的 picture 是最新的,独立于后端缓存。 + // + // 失败策略:best-effort。Graph 调用挂掉 / 用户没头像 / token 过期都不 + // 影响 Facebook 登录主流程 —— 业务 token 已经在 result 里。 + await persistFacebookProfile(input.accessToken); + return "facebook" as LoginStatus; }); +/** + * 用 accessToken 调 Facebook Graph `/me` 拉用户资料,写本地: + * - `id` → `AuthStorage.setFacebookId()`(独立于后端 userId) + * - `pictureUrl` → `UserStorage.setAvatarUrl()`(头像专用 slot,便于快速读取) + * + * 全部 best-effort:单条失败只 warn,不抛错。`pictureUrl == null` 也算成功 + * (用户没设头像),只是不写入。 + */ +async function persistFacebookProfile(accessToken: string): Promise { + const fbR = await fetchFacebookUserData(accessToken); + if (Result.isErr(fbR)) { + log.warn( + { err: fbR.error.message }, + "[auth-machine] syncFacebookBackendActor: fetchFacebookUserData failed (continuing anyway)", + ); + return; + } + const fbUser = fbR.data; + log.info( + { + hasId: !!fbUser.id, + hasPicture: !!fbUser.pictureUrl, + }, + "[auth-machine] syncFacebookBackendActor: Facebook profile fetched", + ); + + const authStorage = AuthStorage.getInstance(); + if (fbUser.id) { + const r = await authStorage.setFacebookId(fbUser.id); + if (!r.success) { + log.warn( + { err: r.error.message }, + "[auth-machine] syncFacebookBackendActor: setFacebookId failed", + ); + } + } + + if (fbUser.pictureUrl) { + const r = await UserStorage.getInstance().setAvatarUrl(fbUser.pictureUrl); + if (!r.success) { + log.warn( + { err: r.error.message }, + "[auth-machine] syncFacebookBackendActor: setAvatarUrl failed", + ); + } + } +} + // ========== 显式游客登录 actor(opt-in) ========== // 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,不自动跑。 // 取代了之前 checkAuthStatusActor 的 auto-guest-login 行为 —— 避免每次 diff --git a/src/utils/facebook-graph.ts b/src/utils/facebook-graph.ts new file mode 100644 index 00000000..e8980a4f --- /dev/null +++ b/src/utils/facebook-graph.ts @@ -0,0 +1,108 @@ +/** + * Facebook Graph API 工具 + * + * 在 OAuth callback 之后、auth state machine 的 `syncingFacebookBackend` actor + * 成功换取业务 token 之后调用 —— 用 accessToken 调 `/me` 拉用户资料(id / name + * / email / picture),把 pictureUrl 写入 `UserStorage.setAvatarUrl()`、 + * id 写入 `AuthStorage.setFacebookId()`。 + * + * 为什么不依赖后端返回的 avatarUrl? + * - 后端可能未抓取 / 未存头像 + * - 头像有时效性(用户改 Facebook 头像),前端直接拉最新的 + * - Facebook ID 也独立存一份(与后端 userId 解耦,给 deep link 用) + * + * 不引入 Facebook SDK(window.FB)—— 减少前端 bundle 体积,Graph API + * 单次 HTTP 调用即可拿到所有字段。SDK 加载慢、还要管 init。 + * + * 端点:`https://graph.facebook.com/me?fields=id,name,email,picture.type=large&access_token=...` + * - `picture.type=large` 返回 ~200px 头像 URL(默认 square 50px 太小) + * - 不指定 version 走最新稳定版 + * - 不需要 appId / appSecret(accessToken 自身就是凭证) + */ +import { FacebookUserData } from "@/data/dto/auth/facebook_user_data"; +import { Logger } from "./logger"; +import { Result, toError } from "./result"; + +const log = new Logger("FacebookGraph"); + +/** Facebook Graph `/me` 端点。 */ +const GRAPH_ME_URL = "https://graph.facebook.com/me"; + +/** + * 拉取字段集合: + * - id / name / email —— 给本地 FacebookUserData DTO + * - picture.type=large —— 头像 URL(默认 square 50px 太小) + */ +const FIELDS = "id,name,email,picture.type=large"; + +/** + * 用 accessToken 调 Facebook Graph `/me` 拉用户资料。 + * + * 错误处理:返回 `Result` 而非 throw —— 调用方在 + * auth state machine actor 内可走「best-effort」路径,失败只 warn 不 + * 让 Facebook 登录整条流挂掉(后端业务 token 已经换到,头像只是锦上添花)。 + */ +export async function fetchFacebookUserData( + accessToken: string, +): Promise> { + if (!accessToken) { + return Result.err(new Error("fetchFacebookUserData: accessToken is empty")); + } + + const url = `${GRAPH_ME_URL}?fields=${encodeURIComponent(FIELDS)}&access_token=${encodeURIComponent(accessToken)}`; + + log.debug({ url: url.replace(accessToken, "") }, "fetchFacebookUserData REQUEST"); + + try { + const res = await fetch(url, { method: "GET" }); + const json = (await res.json()) as Record; + + if (!res.ok) { + const errBody = (json?.error as Record | undefined) ?? {}; + const message = + typeof errBody.message === "string" + ? errBody.message + : `Graph /me failed: HTTP ${res.status}`; + log.warn({ status: res.status, body: errBody }, "fetchFacebookUserData FAILED"); + return Result.err(new Error(message)); + } + + // 提取 picture.data.url —— Graph API 返回嵌套结构 + const pictureUrl = extractPictureUrl(json.picture); + + const data = FacebookUserData.from({ + id: typeof json.id === "string" ? json.id : null, + name: typeof json.name === "string" ? json.name : null, + email: typeof json.email === "string" ? json.email : null, + pictureUrl, + }); + + log.debug( + { + hasId: !!data.id, + hasName: !!data.name, + hasEmail: !!data.email, + hasPicture: !!data.pictureUrl, + }, + "fetchFacebookUserData SUCCESS", + ); + return Result.ok(data); + } catch (e) { + const err = toError(e); + log.error({ err }, "fetchFacebookUserData THREW"); + return Result.err(err); + } +} + +/** + * 安全提取 `picture.data.url` —— 容错处理: + * - 缺字段 / 类型不对 → null(头像可选) + * - Graph API 偶尔返回 `picture: false`(没设头像)→ null + */ +function extractPictureUrl(raw: unknown): string | null { + if (!raw || typeof raw !== "object") return null; + const data = (raw as { data?: unknown }).data; + if (!data || typeof data !== "object") return null; + const url = (data as { url?: unknown }).url; + return typeof url === "string" && url.length > 0 ? url : null; +}