Merge branch 'dev' into test
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
name: git-commit
|
||||||
|
description: 'Execute git commit with conventional commit message analysis, intelligent staging, and message generation. Use when user asks to commit changes, create a git commit, or mentions "/commit". Supports: (1) Auto-detecting type and scope from changes, (2) Generating conventional commit messages from diff, (3) Interactive commit with optional type/scope/description overrides, (4) Intelligent file staging for logical grouping'
|
||||||
|
license: MIT
|
||||||
|
allowed-tools: Bash
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git Commit with Conventional Commits
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Create standardized, semantic git commits using the Conventional Commits specification. Analyze the actual diff to determine appropriate type, scope, and message.
|
||||||
|
|
||||||
|
## Conventional Commit Format
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>[optional scope]: <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit Types
|
||||||
|
|
||||||
|
| Type | Purpose |
|
||||||
|
| ---------- | ------------------------------ |
|
||||||
|
| `feat` | New feature |
|
||||||
|
| `fix` | Bug fix |
|
||||||
|
| `docs` | Documentation only |
|
||||||
|
| `style` | Formatting/style (no logic) |
|
||||||
|
| `refactor` | Code refactor (no feature/fix) |
|
||||||
|
| `perf` | Performance improvement |
|
||||||
|
| `test` | Add/update tests |
|
||||||
|
| `build` | Build system/dependencies |
|
||||||
|
| `ci` | CI/config changes |
|
||||||
|
| `chore` | Maintenance/misc |
|
||||||
|
| `revert` | Revert commit |
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
```
|
||||||
|
# Exclamation mark after type/scope
|
||||||
|
feat!: remove deprecated endpoint
|
||||||
|
|
||||||
|
# BREAKING CHANGE footer
|
||||||
|
feat: allow config to extend other configs
|
||||||
|
|
||||||
|
BREAKING CHANGE: `extends` key behavior changed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Analyze Diff
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If files are staged, use staged diff
|
||||||
|
git diff --staged
|
||||||
|
|
||||||
|
# If nothing staged, use working tree diff
|
||||||
|
git diff
|
||||||
|
|
||||||
|
# Also check status
|
||||||
|
git status --porcelain
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Stage Files (if needed)
|
||||||
|
|
||||||
|
If nothing is staged or you want to group changes differently:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stage specific files
|
||||||
|
git add path/to/file1 path/to/file2
|
||||||
|
|
||||||
|
# Stage by pattern
|
||||||
|
git add *.test.*
|
||||||
|
git add src/components/*
|
||||||
|
|
||||||
|
# Interactive staging
|
||||||
|
git add -p
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never commit secrets** (.env, credentials.json, private keys).
|
||||||
|
|
||||||
|
### 3. Generate Commit Message
|
||||||
|
|
||||||
|
Analyze the diff to determine:
|
||||||
|
|
||||||
|
- **Type**: What kind of change is this?
|
||||||
|
- **Scope**: What area/module is affected?
|
||||||
|
- **Description**: One-line summary of what changed (present tense, imperative mood, <72 chars)
|
||||||
|
|
||||||
|
### 4. Execute Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single line
|
||||||
|
git commit -m "<type>[scope]: <description>"
|
||||||
|
|
||||||
|
# Multi-line with body/footer
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
<type>[scope]: <description>
|
||||||
|
|
||||||
|
<optional body>
|
||||||
|
|
||||||
|
<optional footer>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- One logical change per commit
|
||||||
|
- Present tense: "add" not "added"
|
||||||
|
- Imperative mood: "fix bug" not "fixes bug"
|
||||||
|
- Reference issues: `Closes #123`, `Refs #456`
|
||||||
|
- Keep description under 72 characters
|
||||||
|
|
||||||
|
## Git Safety Protocol
|
||||||
|
|
||||||
|
- NEVER update git config
|
||||||
|
- NEVER run destructive commands (--force, hard reset) without explicit request
|
||||||
|
- NEVER skip hooks (--no-verify) unless user asks
|
||||||
|
- NEVER force push to main/master
|
||||||
|
- If commit fails due to hooks, fix and create NEW commit (don't amend)
|
||||||
@@ -1,15 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
/**
|
/**
|
||||||
* 邮箱登录表单
|
* 邮箱登录表单
|
||||||
*
|
|
||||||
* 原始 Dart: lib/ui/auth/widgets/email_login_form.dart
|
|
||||||
*
|
|
||||||
* 视觉规格(与 Dart 对齐):
|
|
||||||
* - 字段无 label(仅 placeholder)
|
|
||||||
* - 错误仅以红色横幅显示(AuthErrorMessage),无字段级错误
|
|
||||||
* - 不显示 "Forgot password?" 链接
|
|
||||||
* - 切换链接由父级 AuthEmailPanel 渲染
|
|
||||||
* - 提交派发 `AuthEmailLoginSubmitted`
|
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -36,11 +27,41 @@ export function EmailLoginForm({
|
|||||||
const dispatch = useAuthDispatch();
|
const dispatch = useAuthDispatch();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
// 本地校验错误(client-side)—— 校验失败时展示第一个非 null 的错误
|
||||||
|
// 优先级:local validationError > server globalError
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 用户在任一字段开始输入 → 清掉旧校验错误(标准 UX:边改边消)
|
||||||
|
// 直接在 onChange 调 setState —— React useState 对相同值自动 bail-out 不触发额外渲染
|
||||||
|
// 避免 useEffect 内 setState 的"级联渲染"反模式
|
||||||
|
const onEmailChange = (v: string) => {
|
||||||
|
setEmail(v);
|
||||||
|
setValidationError(null);
|
||||||
|
};
|
||||||
|
const onPasswordChange = (v: string) => {
|
||||||
|
setPassword(v);
|
||||||
|
setValidationError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
|
console.log("[email-login-form] submit ENTRY", {
|
||||||
|
emailLength: email.length,
|
||||||
|
emailPreview: email.slice(0, 8),
|
||||||
|
passwordLength: password.length,
|
||||||
|
});
|
||||||
const emailErr = validateEmail(email);
|
const emailErr = validateEmail(email);
|
||||||
const passwordErr = validatePassword(password);
|
const passwordErr = validatePassword(password);
|
||||||
if (emailErr || passwordErr) return;
|
if (emailErr || passwordErr) {
|
||||||
|
const firstErr = emailErr ?? passwordErr ?? null;
|
||||||
|
console.warn(
|
||||||
|
"[email-login-form] submit VALIDATION FAILED (silent return)",
|
||||||
|
{ emailErr, passwordErr, surfaced: firstErr },
|
||||||
|
);
|
||||||
|
setValidationError(firstErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValidationError(null);
|
||||||
|
console.log("[email-login-form] submit dispatching AuthEmailLoginSubmitted");
|
||||||
dispatch({ type: "AuthEmailLoginSubmitted", email, password });
|
dispatch({ type: "AuthEmailLoginSubmitted", email, password });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,19 +76,20 @@ export function EmailLoginForm({
|
|||||||
<AuthTextField
|
<AuthTextField
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={setEmail}
|
onChange={onEmailChange}
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={onPasswordChange}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
/>
|
/>
|
||||||
<AuthErrorMessage message={globalError ?? null} />
|
{/* 优先级:本地校验错误 > 后端 globalError(两个互斥场景:本地校验未过时,UI 不会再有 globalError) */}
|
||||||
|
<AuthErrorMessage message={validationError ?? globalError ?? null} />
|
||||||
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
||||||
Login
|
Login
|
||||||
</AuthPrimaryButton>
|
</AuthPrimaryButton>
|
||||||
|
|||||||
@@ -40,13 +40,64 @@ export function EmailRegisterForm({
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
// 本地校验错误(client-side)—— 校验失败时展示第一个非 null 的错误
|
||||||
|
// 优先级:local validationError > server globalError
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 用户在任一字段开始输入 → 清掉旧校验错误(标准 UX:边改边消)
|
||||||
|
// 直接在 onChange 调 setState —— React useState 对相同值自动 bail-out 不触发额外渲染
|
||||||
|
// 避免 useEffect 内 setState 的"级联渲染"反模式
|
||||||
|
const clearValidationOnType = () => setValidationError(null);
|
||||||
|
const onUsernameChange = (v: string) => {
|
||||||
|
setUsername(v);
|
||||||
|
clearValidationOnType();
|
||||||
|
};
|
||||||
|
const onEmailChange = (v: string) => {
|
||||||
|
setEmail(v);
|
||||||
|
clearValidationOnType();
|
||||||
|
};
|
||||||
|
const onPasswordChange = (v: string) => {
|
||||||
|
setPassword(v);
|
||||||
|
clearValidationOnType();
|
||||||
|
};
|
||||||
|
const onConfirmPasswordChange = (v: string) => {
|
||||||
|
setConfirmPassword(v);
|
||||||
|
clearValidationOnType();
|
||||||
|
};
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
|
console.log("[email-register-form] submit ENTRY", {
|
||||||
|
emailLength: email.length,
|
||||||
|
emailPreview: email.slice(0, 8),
|
||||||
|
passwordLength: password.length,
|
||||||
|
usernameLength: username.length,
|
||||||
|
confirmPasswordLength: confirmPassword.length,
|
||||||
|
});
|
||||||
const emailErr = validateEmail(email);
|
const emailErr = validateEmail(email);
|
||||||
const passwordErr = validatePassword(password);
|
const passwordErr = validatePassword(password);
|
||||||
const usernameErr = validateUsername(username);
|
const usernameErr = validateUsername(username);
|
||||||
const confirmErr = validateConfirmPassword(password, confirmPassword);
|
const confirmErr = validateConfirmPassword(password, confirmPassword);
|
||||||
if (emailErr || passwordErr || usernameErr || confirmErr) return;
|
if (emailErr || passwordErr || usernameErr || confirmErr) {
|
||||||
|
// 按 username → email → password → confirm 顺序挑第一个错(与字段顺序一致)
|
||||||
|
const firstErr =
|
||||||
|
usernameErr ?? emailErr ?? passwordErr ?? confirmErr ?? null;
|
||||||
|
console.warn(
|
||||||
|
"[email-register-form] submit VALIDATION FAILED (silent return)",
|
||||||
|
{
|
||||||
|
emailErr,
|
||||||
|
passwordErr,
|
||||||
|
usernameErr,
|
||||||
|
confirmErr,
|
||||||
|
surfaced: firstErr,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setValidationError(firstErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValidationError(null);
|
||||||
|
console.log(
|
||||||
|
"[email-register-form] submit dispatching AuthEmailRegisterSubmitted",
|
||||||
|
);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "AuthEmailRegisterSubmitted",
|
type: "AuthEmailRegisterSubmitted",
|
||||||
email,
|
email,
|
||||||
@@ -66,33 +117,34 @@ export function EmailRegisterForm({
|
|||||||
>
|
>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
value={username}
|
value={username}
|
||||||
onChange={setUsername}
|
onChange={onUsernameChange}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={setEmail}
|
onChange={onEmailChange}
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={onPasswordChange}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={setConfirmPassword}
|
onChange={onConfirmPasswordChange}
|
||||||
placeholder="Confirm Password"
|
placeholder="Confirm Password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
/>
|
/>
|
||||||
<AuthErrorMessage message={globalError ?? null} />
|
{/* 优先级:本地校验错误 > 后端 globalError */}
|
||||||
|
<AuthErrorMessage message={validationError ?? globalError ?? null} />
|
||||||
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
||||||
Register
|
Register
|
||||||
</AuthPrimaryButton>
|
</AuthPrimaryButton>
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 登录状态枚举
|
* 登录状态枚举
|
||||||
*
|
|
||||||
* 原始 Dart: lib/ui/auth/bloc/auth_state.dart 的 `LoginType` 枚举(v7.0 新增)。
|
|
||||||
*
|
|
||||||
* 2025-XX 改造:语义从"登录方式"扩展为"当前登录状态"——
|
|
||||||
* 包含 notLoggedIn / guest / 各 OAuth provider 登录后态。
|
|
||||||
*
|
|
||||||
* 字段命名(state)从 `loginType` 改为 `loginStatus`,更贴合语义。
|
|
||||||
*/
|
*/
|
||||||
export const LoginStatus = {
|
export const LoginStatus = {
|
||||||
/** 未登录(默认初值) */
|
/** 未登录(默认初值) */
|
||||||
NotLoggedIn: "notLoggedIn",
|
NotLoggedIn: "notLoggedIn",
|
||||||
/** 游客登录(无需账号,10 轮免费消息) */
|
/** 游客登录 */
|
||||||
Guest: "guest",
|
Guest: "guest",
|
||||||
/** Facebook OAuth 登录 */
|
/** Facebook OAuth 登录 */
|
||||||
Facebook: "facebook",
|
Facebook: "facebook",
|
||||||
|
|||||||
@@ -15,18 +15,37 @@ 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;
|
||||||
|
|
||||||
export const emailLoginActor = fromPromise<LoginStatus, { email: string; password: string }>(
|
export const emailLoginActor = fromPromise<LoginStatus, { email: string; password: string }>(
|
||||||
async ({ input }) => {
|
async ({ input }) => {
|
||||||
|
console.log("[auth-machine] emailLoginActor ENTRY", {
|
||||||
|
emailLength: input.email.length,
|
||||||
|
emailPreview: input.email.slice(0, 8),
|
||||||
|
passwordLength: input.password.length,
|
||||||
|
});
|
||||||
const guestId = await readGuestId();
|
const guestId = await readGuestId();
|
||||||
|
console.log("[auth-machine] emailLoginActor calling authRepo.emailLogin", {
|
||||||
|
hasGuestId: !!guestId,
|
||||||
|
});
|
||||||
const result = await authRepo.emailLogin({ ...input, guestId });
|
const result = await authRepo.emailLogin({ ...input, guestId });
|
||||||
|
console.log("[auth-machine] emailLoginActor authRepo.emailLogin DONE", {
|
||||||
|
success: result.success,
|
||||||
|
hasData: result.success ? !!result.data : null,
|
||||||
|
errorName: result.success ? null : result.error?.name,
|
||||||
|
errorMessage: result.success ? null : result.error?.message,
|
||||||
|
});
|
||||||
if (Result.isErr(result)) throw result.error;
|
if (Result.isErr(result)) throw result.error;
|
||||||
return "email" as LoginStatus;
|
return "email" as LoginStatus;
|
||||||
},
|
},
|
||||||
@@ -36,17 +55,40 @@ export const emailRegisterThenLoginActor = fromPromise<
|
|||||||
LoginStatus,
|
LoginStatus,
|
||||||
{ email: string; password: string; username: string; confirmPassword: string }
|
{ email: string; password: string; username: string; confirmPassword: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
|
console.log("[auth-machine] emailRegisterThenLoginActor ENTRY", {
|
||||||
|
emailLength: input.email.length,
|
||||||
|
usernameLength: input.username.length,
|
||||||
|
passwordLength: input.password.length,
|
||||||
|
confirmPasswordLength: input.confirmPassword.length,
|
||||||
|
});
|
||||||
const guestId = await readGuestId();
|
const guestId = await readGuestId();
|
||||||
|
|
||||||
|
console.log("[auth-machine] emailRegisterThenLoginActor calling authRepo.register");
|
||||||
const registerResult = await authRepo.register({ ...input, guestId });
|
const registerResult = await authRepo.register({ ...input, guestId });
|
||||||
|
console.log("[auth-machine] emailRegisterThenLoginActor authRepo.register DONE", {
|
||||||
|
success: registerResult.success,
|
||||||
|
errorName: registerResult.success ? null : registerResult.error?.name,
|
||||||
|
errorMessage: registerResult.success ? null : registerResult.error?.message,
|
||||||
|
});
|
||||||
if (Result.isErr(registerResult)) throw registerResult.error;
|
if (Result.isErr(registerResult)) throw registerResult.error;
|
||||||
|
|
||||||
// 注册后自动登录(对齐 Dart 行为)
|
// 注册后自动登录(对齐 Dart 行为)
|
||||||
|
console.log(
|
||||||
|
"[auth-machine] emailRegisterThenLoginActor calling authRepo.emailLogin (post-register)",
|
||||||
|
);
|
||||||
const loginResult = await authRepo.emailLogin({
|
const loginResult = await authRepo.emailLogin({
|
||||||
email: input.email,
|
email: input.email,
|
||||||
password: input.password,
|
password: input.password,
|
||||||
guestId,
|
guestId,
|
||||||
});
|
});
|
||||||
|
console.log(
|
||||||
|
"[auth-machine] emailRegisterThenLoginActor authRepo.emailLogin DONE",
|
||||||
|
{
|
||||||
|
success: loginResult.success,
|
||||||
|
errorName: loginResult.success ? null : loginResult.error?.name,
|
||||||
|
errorMessage: loginResult.success ? null : loginResult.error?.message,
|
||||||
|
},
|
||||||
|
);
|
||||||
if (Result.isErr(loginResult)) throw loginResult.error;
|
if (Result.isErr(loginResult)) throw loginResult.error;
|
||||||
return "email" as LoginStatus;
|
return "email" as LoginStatus;
|
||||||
});
|
});
|
||||||
@@ -89,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 行为 —— 避免每次
|
||||||
|
|||||||
@@ -83,21 +83,21 @@ export const authMachine = setup({
|
|||||||
},
|
},
|
||||||
// 启动 / 恢复时检查登录态 —— 由 <AuthStatusChecker /> 派发
|
// 启动 / 恢复时检查登录态 —— 由 <AuthStatusChecker /> 派发
|
||||||
AuthStatusCheckSubmitted: "checkingAuthStatus",
|
AuthStatusCheckSubmitted: "checkingAuthStatus",
|
||||||
// 显式游客登录 —— 由 splash UI 派发(不自动)
|
|
||||||
AuthGuestLoginSubmitted: "loadingGuestLogin",
|
AuthGuestLoginSubmitted: "loadingGuestLogin",
|
||||||
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
||||||
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
||||||
AuthGoogleLoginSubmitted: "loadingOAuth",
|
AuthGoogleLoginSubmitted: "loadingOAuth",
|
||||||
AuthFacebookLoginSubmitted: "loadingOAuth",
|
AuthFacebookLoginSubmitted: "loadingOAuth",
|
||||||
|
|
||||||
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
|
|
||||||
AuthGoogleSyncSubmitted: "syncingGoogleBackend",
|
|
||||||
AuthFacebookSyncSubmitted: "syncingFacebookBackend",
|
|
||||||
AuthAppleLoginSubmitted: {
|
AuthAppleLoginSubmitted: {
|
||||||
actions: assign({
|
actions: assign({
|
||||||
errorMessage: "Apple login not implemented",
|
errorMessage: "Apple login not implemented",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
|
||||||
|
AuthGoogleSyncSubmitted: "syncingGoogleBackend",
|
||||||
|
AuthFacebookSyncSubmitted: "syncingFacebookBackend",
|
||||||
// splash / auth screen 跳完 /chat 后清flag
|
// splash / auth screen 跳完 /chat 后清flag
|
||||||
AuthClearPendingRedirect: {
|
AuthClearPendingRedirect: {
|
||||||
actions: assign({ pendingRedirect: false }),
|
actions: assign({ pendingRedirect: false }),
|
||||||
@@ -151,6 +151,7 @@ export const authMachine = setup({
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadingEmailLogin: {
|
loadingEmailLogin: {
|
||||||
|
entry: assign({ errorMessage: null }),
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "emailLogin",
|
src: "emailLogin",
|
||||||
input: ({ event }) => {
|
input: ({ event }) => {
|
||||||
@@ -177,6 +178,7 @@ export const authMachine = setup({
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadingEmailRegister: {
|
loadingEmailRegister: {
|
||||||
|
entry: assign({ errorMessage: null }),
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "emailRegisterThenLogin",
|
src: "emailRegisterThenLogin",
|
||||||
input: ({ event }) => {
|
input: ({ event }) => {
|
||||||
|
|||||||
@@ -1,25 +1,74 @@
|
|||||||
"use client";
|
"use client";
|
||||||
/**
|
/**
|
||||||
* AuthStatusChecker 启动检查器
|
* AuthStatusChecker 启动 + 状态变化检查器
|
||||||
*
|
*
|
||||||
* App 启动时派发一次 `AuthStatusCheckSubmitted`,
|
* 触发时机:
|
||||||
* 让 auth machine 走"拿 deviceId → 查 token → 必要时游客登录"流程。
|
* 1. App 启动时(mount)派发一次 `AuthStatusCheckSubmitted`
|
||||||
|
* 2. loginStatus 每次变化时(外部触发)也派发一次 —— 重新读 storage 校准
|
||||||
|
* 例:OAuth sync 完成 → loginStatus 变 "facebook" → re-check 一次确认 storage 一致
|
||||||
|
* 邮箱登录成功 → loginStatus 变 "email" → re-check 确认 token 已落盘
|
||||||
|
*
|
||||||
|
* 死循环防护(关键 —— 不加会无限递归):
|
||||||
|
* - 状态机收 `AuthStatusCheckSubmitted` → 进 `checkingAuthStatus` → 读 storage → onDone 写回 loginStatus
|
||||||
|
* - 如果 useEffect 直接依赖 `loginStatus`,写回本身触发 effect → 再 dispatch → 死循环
|
||||||
|
* - 用 `skipNextChangeRef` 抑制一次:
|
||||||
|
* 每次我们自己 dispatch 前把 flag 置 true;
|
||||||
|
* effect 见到 loginStatus 变化且 flag 为 true 时跳过并清 flag。
|
||||||
*
|
*
|
||||||
* 与 <OAuthSessionSync /> 平级:
|
* 与 <OAuthSessionSync /> 平级:
|
||||||
* - AuthStatusChecker: 一次性 init
|
* - AuthStatusChecker: 启动 + 状态变化时 re-check("自己也能感知")
|
||||||
* - OAuthSessionSync: 持续监听 NextAuth session
|
* - OAuthSessionSync: 持续监听 NextAuth session("别人也在写")
|
||||||
*/
|
*/
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { useAuthDispatch } from "./auth-context";
|
import type { LoginStatus } from "@/models/auth/login-status";
|
||||||
|
|
||||||
|
import { useAuthDispatch, useAuthState } from "./auth-context";
|
||||||
|
|
||||||
export function AuthStatusChecker() {
|
export function AuthStatusChecker() {
|
||||||
const dispatch = useAuthDispatch();
|
const dispatch = useAuthDispatch();
|
||||||
|
const loginStatus = useAuthState().loginStatus;
|
||||||
|
|
||||||
|
// 上一次的 loginStatus(首次 mount 留 null —— 用来跳过首次 effect 跑)
|
||||||
|
const prevLoginStatusRef = useRef<LoginStatus | null>(null);
|
||||||
|
// 抑制下一次 loginStatus 变化触发的 effect(自己 dispatch 引起的写回要跳过)
|
||||||
|
const skipNextChangeRef = useRef(false);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// ① App 启动时:派发一次
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log("[auth-status-checker] mount → dispatch AuthStatusCheckSubmitted");
|
||||||
|
skipNextChangeRef.current = true; // 抑制启动 check 写回 loginStatus 引起的 effect
|
||||||
dispatch({ type: "AuthStatusCheckSubmitted" });
|
dispatch({ type: "AuthStatusCheckSubmitted" });
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// ② loginStatus 变化时:派发一次(但跳过我们自己 check 写回的那次)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevLoginStatusRef.current;
|
||||||
|
prevLoginStatusRef.current = loginStatus;
|
||||||
|
if (prev === null) return; // 首次 mount —— 启动那次由 ① 处理
|
||||||
|
if (prev === loginStatus) return; // loginStatus 没变 —— 不触发
|
||||||
|
if (skipNextChangeRef.current) {
|
||||||
|
// 这是我们自己 check 写回的 —— 抑制本次
|
||||||
|
skipNextChangeRef.current = false;
|
||||||
|
console.log(
|
||||||
|
"[auth-status-checker] loginStatus change suppressed (self-triggered)",
|
||||||
|
{ from: prev, to: loginStatus },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 外部触发(OAuth sync / 邮箱登录 / 登出 等)的 loginStatus 变化 → 再 check 一次校准
|
||||||
|
skipNextChangeRef.current = true;
|
||||||
|
console.log(
|
||||||
|
"[auth-status-checker] loginStatus changed externally → re-check",
|
||||||
|
{ from: prev, to: loginStatus },
|
||||||
|
);
|
||||||
|
dispatch({ type: "AuthStatusCheckSubmitted" });
|
||||||
|
}, [loginStatus, dispatch]);
|
||||||
|
|
||||||
// 无 UI
|
// 无 UI
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
import { fromPromise, fromCallback } from "xstate";
|
import { fromPromise, fromCallback } from "xstate";
|
||||||
|
|
||||||
import { createChatWebSocket } from "@/core/net/chat-websocket";
|
import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,28 @@ import {
|
|||||||
} from "./chat-machine.helpers";
|
} from "./chat-machine.helpers";
|
||||||
import type { ChatEvent } from "./chat-events";
|
import type { ChatEvent } from "./chat-events";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 共享 WS 引用(chatWebSocketActor ↔ sendMessageWsActor)
|
||||||
|
// ============================================================
|
||||||
|
/**
|
||||||
|
* 活跃 ChatWebSocket 单例引用(module-level)。
|
||||||
|
* - `chatWebSocketActor` 在 connect 后写入、cleanup 时清空
|
||||||
|
* - `sendMessageWsActor` 读取并调 `ws.sendMessage(content)`
|
||||||
|
*
|
||||||
|
* 为什么用 module-level 而非 context.wsRef:
|
||||||
|
* 1) `ChatWebSocket` 是带回调 + 重连 timer 的非可序列化对象,放进
|
||||||
|
* XState context 会污染快照、警告 non-serializable
|
||||||
|
* 2) userSession 同一时间只可能有一个活跃 WS(parent-level invoke),
|
||||||
|
* module 单例的语义和"userSession 内一个活跃连接"一一对应
|
||||||
|
* 3) 不需要重构 ChatWebSocket 类的对外 API
|
||||||
|
*/
|
||||||
|
let activeChatWebSocket: ChatWebSocket | null = null;
|
||||||
|
|
||||||
|
/** 暴露给 `sendMessageWsActor` 读取(测试也可 import) */
|
||||||
|
export function getActiveChatWebSocket(): ChatWebSocket | null {
|
||||||
|
return activeChatWebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Init 任务 1:拉游客配额(fromPromise,纯 local)
|
// Init 任务 1:拉游客配额(fromPromise,纯 local)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -196,6 +218,13 @@ export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
|
|||||||
console.log("[chat-machine] chatWebSocketActor createChatWebSocket DONE", {
|
console.log("[chat-machine] chatWebSocketActor createChatWebSocket DONE", {
|
||||||
wsType: ws.constructor.name,
|
wsType: ws.constructor.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 写入 module-level 共享引用 —— sendMessageWsActor 借此调 ws.sendMessage()
|
||||||
|
activeChatWebSocket = ws;
|
||||||
|
console.log("[chat-machine] chatWebSocketActor activeChatWebSocket SET", {
|
||||||
|
isConnected: ws.isConnected,
|
||||||
|
});
|
||||||
|
|
||||||
ws.onConnected = (userId) => {
|
ws.onConnected = (userId) => {
|
||||||
console.log("[chat-machine] WS onConnected", {
|
console.log("[chat-machine] WS onConnected", {
|
||||||
userId,
|
userId,
|
||||||
@@ -234,10 +263,62 @@ export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
|
|||||||
console.log(
|
console.log(
|
||||||
"[chat-machine] chatWebSocketActor CLEANUP (parent state exit / logout / cross-transition / unmount)",
|
"[chat-machine] chatWebSocketActor CLEANUP (parent state exit / logout / cross-transition / unmount)",
|
||||||
);
|
);
|
||||||
|
// 清空 module-level 引用 —— 严格用 `=== ws` 判等,避免覆盖更新的实例
|
||||||
|
if (activeChatWebSocket === ws) {
|
||||||
|
activeChatWebSocket = null;
|
||||||
|
console.log("[chat-machine] chatWebSocketActor activeChatWebSocket CLEARED");
|
||||||
|
}
|
||||||
ws.disconnect();
|
ws.disconnect();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// WebSocket: per-send callback
|
||||||
|
// ============================================================
|
||||||
|
/**
|
||||||
|
* WS 发送消息(userSession.sendingViaWs 期间 invoke)
|
||||||
|
* - 走 `activeChatWebSocket.sendMessage(content)`
|
||||||
|
* - 成功后 AI 回复通过 `chatWebSocketActor` 的 `onSentence` → `ChatAISentenceReceived` 流回
|
||||||
|
* - 失败抛错 → state machine 的 `onError` 兜底(清 isReplyingAI、回 ready)
|
||||||
|
*
|
||||||
|
* 与 sendMessageHttpActor 的区别:
|
||||||
|
* - sendMessageHttpActor: 一次发送 = 一次返回(HTTP response = AI reply)
|
||||||
|
* - sendMessageWsActor: 一次发送 = 0 次直接返回(WS 异步流式回 reply,actor 本身不返 reply)
|
||||||
|
*
|
||||||
|
* 选 fromPromise 而非 fromCallback:
|
||||||
|
* - 本 actor 是 fire-and-forget —— sync 内同步执行 ws.sendMessage,立即 resolve
|
||||||
|
* - actor 本身没有"完成"语义(done 来自 WS 事件流,不来自这个 actor)
|
||||||
|
* - 抛错走 onError 通道
|
||||||
|
* - fromPromise 的 input type 推断比 fromCallback<void, ...> 稳定(XState v5 已知坑)
|
||||||
|
*/
|
||||||
|
export const sendMessageWsActor = fromPromise<void, { content: string }>(
|
||||||
|
async ({ input }) => {
|
||||||
|
console.log("[chat-machine] sendMessageWsActor ENTRY", {
|
||||||
|
contentLength: input.content.length,
|
||||||
|
contentPreview: input.content.slice(0, 50),
|
||||||
|
});
|
||||||
|
const ws = getActiveChatWebSocket();
|
||||||
|
if (!ws) {
|
||||||
|
console.error("[chat-machine] sendMessageWsActor no activeChatWebSocket");
|
||||||
|
throw new Error("[chat-machine] sendMessageWs: no active WebSocket (actor not running)");
|
||||||
|
}
|
||||||
|
console.log("[chat-machine] sendMessageWsActor calling ws.sendMessage", {
|
||||||
|
isConnected: ws.isConnected,
|
||||||
|
});
|
||||||
|
const ok = ws.sendMessage(input.content);
|
||||||
|
if (!ok) {
|
||||||
|
console.error(
|
||||||
|
"[chat-machine] sendMessageWsActor ws.sendMessage returned false (not OPEN)",
|
||||||
|
);
|
||||||
|
throw new Error("[chat-machine] sendMessageWs: WebSocket not OPEN");
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"[chat-machine] sendMessageWsActor ws.sendMessage OK, awaiting AI sentence stream",
|
||||||
|
);
|
||||||
|
// resolve 不返值 —— AI reply 由 chatWebSocketActor 的 ChatAISentenceReceived 流回
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Re-export `UiMessage` type for the chat-machine.ts public API
|
// Re-export `UiMessage` type for the chat-machine.ts public API
|
||||||
type UiMessage = import("@/models/chat/ui-message").UiMessage;
|
type UiMessage = import("@/models/chat/ui-message").UiMessage;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
loadQuotaActor,
|
loadQuotaActor,
|
||||||
loadHistoryActor,
|
loadHistoryActor,
|
||||||
sendMessageHttpActor,
|
sendMessageHttpActor,
|
||||||
|
sendMessageWsActor,
|
||||||
loadMoreHistoryActor,
|
loadMoreHistoryActor,
|
||||||
chatWebSocketActor,
|
chatWebSocketActor,
|
||||||
} from "./chat-machine.actors";
|
} from "./chat-machine.actors";
|
||||||
@@ -69,6 +70,7 @@ export const chatMachine = setup({
|
|||||||
loadQuota: loadQuotaActor,
|
loadQuota: loadQuotaActor,
|
||||||
loadHistory: loadHistoryActor,
|
loadHistory: loadHistoryActor,
|
||||||
sendMessageHttp: sendMessageHttpActor,
|
sendMessageHttp: sendMessageHttpActor,
|
||||||
|
sendMessageWs: sendMessageWsActor,
|
||||||
loadMoreHistory: loadMoreHistoryActor,
|
loadMoreHistory: loadMoreHistoryActor,
|
||||||
chatWebSocket: chatWebSocketActor,
|
chatWebSocket: chatWebSocketActor,
|
||||||
},
|
},
|
||||||
@@ -344,10 +346,23 @@ export const chatMachine = setup({
|
|||||||
// userSession(非游客会话 —— WS 父级 + history 子级)
|
// userSession(非游客会话 —— WS 父级 + history 子级)
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
userSession: {
|
userSession: {
|
||||||
|
// 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 userSession.on
|
||||||
|
// —— 任何 child state(initializing / ready / sendingViaWs / sendingViaHttp / loadingMore)
|
||||||
|
// 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages,
|
||||||
|
// 这点是 sendingViaWs 流式回 reply 的关键(machine 当时在 sendingViaWs)
|
||||||
|
//
|
||||||
|
// 子级 override 规则:
|
||||||
|
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action)
|
||||||
|
// —— XState v5: child handler 替换 parent;复制 action
|
||||||
|
on: {
|
||||||
|
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
||||||
|
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||||||
|
ChatWebSocketConnected: { actions: "setWsConnected" },
|
||||||
|
},
|
||||||
initial: "initializing",
|
initial: "initializing",
|
||||||
exit: "clearWsConnected",
|
exit: "clearWsConnected",
|
||||||
invoke: {
|
invoke: {
|
||||||
// 父级:WS 长连接(一进来就连,跨 initializing/ready/sending/loadingMore)
|
// 父级:WS 长连接(一进来就连,跨 initializing/ready/sending*/loadingMore)
|
||||||
src: "chatWebSocket",
|
src: "chatWebSocket",
|
||||||
input: ({ event }) => ({
|
input: ({ event }) => ({
|
||||||
token: event.type === "ChatNonGuestLogin" ? event.token : "",
|
token: event.type === "ChatNonGuestLogin" ? event.token : "",
|
||||||
@@ -385,11 +400,24 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
ready: {
|
ready: {
|
||||||
on: {
|
on: {
|
||||||
ChatSendMessage: {
|
// 发送消息:wsConnected → 走 WS(流式回 reply),否则 → 走 HTTP(一次性回 reply)
|
||||||
|
// 两个 branch 都有 "content 非空" guard(仅作 fallback 兜底:wsConnected=true 也需
|
||||||
|
// 内容非空;wsConnected=false 同理)
|
||||||
|
ChatSendMessage: [
|
||||||
|
{
|
||||||
|
// WS 已连 + 内容非空 → 走 WS(sendingViaWs 等 AI 流式回 reply)
|
||||||
|
guard: ({ context, event }) =>
|
||||||
|
context.wsConnected && event.content.trim().length > 0,
|
||||||
actions: "appendUserMessage",
|
actions: "appendUserMessage",
|
||||||
guard: ({ event }) => event.content.trim().length > 0,
|
target: "sendingViaWs",
|
||||||
target: "sending",
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// WS 未连(或 fallback) + 内容非空 → 走 HTTP(sendingViaHttp 一次性回 reply)
|
||||||
|
guard: ({ event }) => event.content.trim().length > 0,
|
||||||
|
actions: "appendUserMessage",
|
||||||
|
target: "sendingViaHttp",
|
||||||
|
},
|
||||||
|
],
|
||||||
ChatSendImage: {
|
ChatSendImage: {
|
||||||
actions: "appendUserImage",
|
actions: "appendUserImage",
|
||||||
},
|
},
|
||||||
@@ -398,13 +426,45 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
ChatLogout: "#chat.idle",
|
ChatLogout: "#chat.idle",
|
||||||
ChatGuestLogin: "#chat.guestSession",
|
ChatGuestLogin: "#chat.guestSession",
|
||||||
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
|
||||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
|
||||||
ChatWebSocketConnected: { actions: "setWsConnected" },
|
|
||||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||||||
|
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
|
||||||
|
// 已上提到 userSession.on,子级不再声明
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sending: {
|
// WS 发送:invoke sendMessageWsActor 触发一次 send,
|
||||||
|
// 之后 AI 流式回 reply 走 userSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence
|
||||||
|
// 当句尾 done: true → isReplyingAI = false → always 跳回 ready
|
||||||
|
sendingViaWs: {
|
||||||
|
on: {
|
||||||
|
// 流式回包中 WS 出错(mid-stream) → 加错误泡 + 跳回 ready(不卡在 sendingViaWs)
|
||||||
|
// 这个 child handler 替换 userSession.on 的 ChatWebSocketError(XState v5 不合并)
|
||||||
|
// 所以需要重复声明 action: "appendSocketErrorMessage"
|
||||||
|
ChatWebSocketError: {
|
||||||
|
target: "ready",
|
||||||
|
actions: "appendSocketErrorMessage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invoke: {
|
||||||
|
src: "sendMessageWs",
|
||||||
|
input: ({ event }) => ({
|
||||||
|
content: event.type === "ChatSendMessage" ? event.content : "",
|
||||||
|
}),
|
||||||
|
onError: {
|
||||||
|
// sendMessageWsActor 自身抛错(无 active ws / ws not OPEN) → 回 ready + 清 typing
|
||||||
|
target: "ready",
|
||||||
|
actions: assign({ isReplyingAI: false }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 监听 isReplyingAI —— appendOrUpdateAISentence 在 done: true 时设 false
|
||||||
|
always: [
|
||||||
|
{
|
||||||
|
target: "ready",
|
||||||
|
guard: ({ context }) => !context.isReplyingAI,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// HTTP 发送:一次发送 = 一次返回(保留原 sending 状态语义,guest 与 ws-down fallback 走此)
|
||||||
|
sendingViaHttp: {
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "sendMessageHttp",
|
src: "sendMessageHttp",
|
||||||
input: ({ event }) => ({
|
input: ({ event }) => ({
|
||||||
|
|||||||
@@ -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