feat: add google oauth

This commit is contained in:
liuyuanchuang
2026-03-06 14:30:30 +08:00
parent bc4b547e03
commit f70a9a85c8
24 changed files with 3593 additions and 334 deletions

View File

@@ -73,6 +73,7 @@ export class ApiError extends Error {
*/
interface RequestConfig extends RequestInit {
skipAuth?: boolean;
successCodes?: number[];
}
/**
@@ -82,7 +83,7 @@ async function request<T>(
endpoint: string,
config: RequestConfig = {}
): Promise<ApiResponse<T>> {
const { skipAuth = false, headers: customHeaders, ...restConfig } = config;
const { skipAuth = false, successCodes = [200], headers: customHeaders, ...restConfig } = config;
const headers: HeadersInit = {
'Content-Type': 'application/json',
@@ -108,7 +109,7 @@ async function request<T>(
const data: ApiResponse<T> = await response.json();
// 统一处理业务错误
if (data.code !== 200) {
if (!successCodes.includes(data.code)) {
throw new ApiError(data.code, data.message, data.request_id);
}
@@ -153,4 +154,3 @@ export const http = {
};
export default http;

View File

@@ -1,36 +1,61 @@
/**
* 认证服务
* 处理用户登录、注册、登出等认证相关操作
* 处理用户登录、注册、OAuth、登出等认证相关操作
*/
import { http, tokenManager, ApiError } from './api';
import type { AuthData, LoginRequest, RegisterRequest, UserInfo } from '../types/api';
import type {
AuthData,
GoogleAuthUrlData,
GoogleOAuthCallbackRequest,
LoginRequest,
RegisterRequest,
UserInfoData,
UserInfo,
} from '../types/api';
// 重新导出 ApiErrorMessages 以便使用
export { ApiErrorMessages } from '../types/api';
/**
* 从 JWT Token 解析用户信息
*/
function parseJwtPayload(token: string): UserInfo | null {
function decodeJwtPayload(token: string): UserInfo | null {
try {
// 移除 Bearer 前缀
const actualToken = token.replace('Bearer ', '');
const base64Payload = actualToken.split('.')[1];
const payload = JSON.parse(atob(base64Payload));
const normalized = base64Payload.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
const payload = JSON.parse(atob(padded));
return payload as UserInfo;
} catch {
return null;
}
}
/**
* 认证服务
*/
function normalizeUser(payload: UserInfo, emailHint?: string): UserInfo {
return {
...payload,
email: payload.email || emailHint || '',
id: String(payload.user_id),
};
}
function buildSession(authData: AuthData, emailHint?: string): { user: UserInfo; token: string; expiresAt: number } {
const { token, expires_at } = authData;
const parsedUser = decodeJwtPayload(token);
if (!parsedUser) {
throw new ApiError(-1, 'Token 解析失败');
}
const user = normalizeUser(parsedUser, emailHint);
tokenManager.setToken(token, expires_at, user.email || undefined);
return {
user,
token,
expiresAt: expires_at,
};
}
export const authService = {
/**
* 用户登录
*/
async login(credentials: LoginRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
const response = await http.post<AuthData>('/user/login', credentials, { skipAuth: true });
@@ -38,35 +63,9 @@ export const authService = {
throw new ApiError(-1, '登录失败,请重试');
}
const { token, expires_at } = response.data;
// 存储 Token 和 email
tokenManager.setToken(token, expires_at, credentials.email);
// 解析用户信息
const user = parseJwtPayload(token);
if (!user) {
throw new ApiError(-1, 'Token 解析失败');
}
// 补充 email 和 id 兼容字段
const userWithEmail: UserInfo = {
...user,
email: credentials.email,
id: String(user.user_id),
};
return {
user: userWithEmail,
token,
expiresAt: expires_at,
};
return buildSession(response.data, credentials.email);
},
/**
* 用户注册
* 注意:业务错误码 (code !== 200) 已在 api.ts 中统一处理,会抛出 ApiError
*/
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });
@@ -74,55 +73,58 @@ export const authService = {
throw new ApiError(-1, '注册失败,请重试');
}
const { token, expires_at } = response.data;
// 存储 Token 和 email
tokenManager.setToken(token, expires_at, credentials.email);
// 解析用户信息
const user = parseJwtPayload(token);
if (!user) {
throw new ApiError(-1, 'Token 解析失败');
}
// 补充 email 和 id 兼容字段
const userWithEmail: UserInfo = {
...user,
email: credentials.email,
id: String(user.user_id),
};
return {
user: userWithEmail,
token,
expiresAt: expires_at,
};
return buildSession(response.data, credentials.email);
},
async getGoogleOAuthUrl(redirectUri: string, state: string): Promise<{ authUrl: string }> {
const query = new URLSearchParams({ redirect_uri: redirectUri, state });
const response = await http.get<GoogleAuthUrlData>(`/user/oauth/google/url?${query.toString()}`, {
skipAuth: true,
});
if (!response.data?.auth_url) {
throw new ApiError(-1, '获取 Google 授权地址失败');
}
return { authUrl: response.data.auth_url };
},
async exchangeGoogleCode(
payload: GoogleOAuthCallbackRequest
): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
const response = await http.post<AuthData>('/user/oauth/google/callback', payload, { skipAuth: true });
if (!response.data) {
throw new ApiError(-1, 'Google 登录失败,请重试');
}
return buildSession(response.data);
},
async getUserInfo(): Promise<UserInfoData> {
const response = await http.get<UserInfoData>('/user/info', {
successCodes: [0, 200],
});
if (!response.data) {
throw new ApiError(-1, '获取用户信息失败');
}
return response.data;
},
/**
* 用户登出
*/
logout(): void {
tokenManager.removeToken();
},
/**
* 检查是否已登录
*/
isAuthenticated(): boolean {
return tokenManager.isTokenValid();
},
/**
* 获取当前存储的 Token
*/
getToken(): string | null {
return tokenManager.getToken();
},
/**
* 从存储的 Token 恢复用户会话
*/
restoreSession(): { user: UserInfo; token: string; expiresAt: number } | null {
const token = tokenManager.getToken();
const expiresAt = tokenManager.getExpiresAt();
@@ -133,21 +135,14 @@ export const authService = {
return null;
}
const parsedUser = parseJwtPayload(token);
const parsedUser = decodeJwtPayload(token);
if (!parsedUser) {
tokenManager.removeToken();
return null;
}
// 补充 email 和 id 兼容字段
const user: UserInfo = {
...parsedUser,
email: email || '',
id: String(parsedUser.user_id),
};
return {
user,
user: normalizeUser(parsedUser, email || ''),
token,
expiresAt,
};
@@ -156,4 +151,3 @@ export const authService = {
export { ApiError };
export default authService;

View File

@@ -60,6 +60,15 @@ export const translations = {
genericError: 'An error occurred, please try again',
hasAccount: 'Already have an account? Login',
noAccount: 'No account? Register',
continueWithGoogle: 'Google',
emailHint: 'Used only for sign-in and history sync.',
passwordHint: 'Use at least 6 characters. Letters and numbers are recommended.',
confirmPassword: 'Confirm Password',
passwordMismatch: 'The two passwords do not match.',
oauthRedirecting: 'Redirecting to Google...',
oauthExchanging: 'Completing Google sign-in...',
invalidOAuthState: 'Invalid OAuth state, please retry.',
oauthFailed: 'Google sign-in failed, please retry.',
},
export: {
title: 'Export',
@@ -156,6 +165,15 @@ export const translations = {
genericError: '发生错误,请重试',
hasAccount: '已有账号?去登录',
noAccount: '没有账号?去注册',
continueWithGoogle: 'Google',
emailHint: '仅用于登录和同步记录。',
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
confirmPassword: '确认密码',
passwordMismatch: '两次输入的密码不一致。',
oauthRedirecting: '正在跳转 Google...',
oauthExchanging: '正在完成 Google 登录...',
invalidOAuthState: 'OAuth 状态校验失败,请重试',
oauthFailed: 'Google 登录失败,请重试',
},
export: {
title: '导出',