feat(auth): implement Facebook user data fetching from Graph API and persist profile information
This commit is contained in:
@@ -15,11 +15,16 @@ import type { LoginStatus } from "@/models/auth/login-status";
|
|||||||
import { authRepository } from "@/data/repositories/auth_repository";
|
import { authRepository } from "@/data/repositories/auth_repository";
|
||||||
import type { IAuthRepository } from "@/data/repositories/interfaces";
|
import type { IAuthRepository } from "@/data/repositories/interfaces";
|
||||||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||||
|
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||||
import { deviceIdentifier } from "@/utils/device_identifier";
|
import { deviceIdentifier } from "@/utils/device_identifier";
|
||||||
|
import { fetchFacebookUserData } from "@/utils/facebook-graph";
|
||||||
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
import { readGuestId } from "./auth-helpers";
|
import { readGuestId } from "./auth-helpers";
|
||||||
|
|
||||||
|
const log = new Logger("AuthActors");
|
||||||
|
|
||||||
// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例
|
// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例
|
||||||
const authRepo: IAuthRepository = authRepository;
|
const authRepo: IAuthRepository = authRepository;
|
||||||
|
|
||||||
@@ -126,9 +131,70 @@ export const syncFacebookBackendActor = fromPromise<
|
|||||||
guestId,
|
guestId,
|
||||||
});
|
});
|
||||||
if (Result.isErr(result)) throw result.error;
|
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;
|
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<void> {
|
||||||
|
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) ==========
|
// ========== 显式游客登录 actor(opt-in) ==========
|
||||||
// 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,不自动跑。
|
// 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,不自动跑。
|
||||||
// 取代了之前 checkAuthStatusActor 的 auto-guest-login 行为 —— 避免每次
|
// 取代了之前 checkAuthStatusActor 的 auto-guest-login 行为 —— 避免每次
|
||||||
|
|||||||
@@ -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<FacebookUserData>` 而非 throw —— 调用方在
|
||||||
|
* auth state machine actor 内可走「best-effort」路径,失败只 warn 不
|
||||||
|
* 让 Facebook 登录整条流挂掉(后端业务 token 已经换到,头像只是锦上添花)。
|
||||||
|
*/
|
||||||
|
export async function fetchFacebookUserData(
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<Result<FacebookUserData>> {
|
||||||
|
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, "<redacted>") }, "fetchFacebookUserData REQUEST");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: "GET" });
|
||||||
|
const json = (await res.json()) as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = (json?.error as Record<string, unknown> | 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user