Squash merge feat/expo-app: app-expo, .cursor, workflows, package.json, .husky; remove app-android, app-ios, react-app
This commit is contained in:
93
app-expo/src/features/auth/api.ts
Normal file
93
app-expo/src/features/auth/api.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { api } from '@/core/api/client';
|
||||
|
||||
import type {
|
||||
ChangePasswordRequest,
|
||||
ChangePhoneRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
ResetPasswordRequest,
|
||||
SmsLoginRequest,
|
||||
SmsRegisterRequest,
|
||||
SmsRequest,
|
||||
TokenResponse,
|
||||
UpdateNicknameRequest,
|
||||
UserInfo,
|
||||
} from './types';
|
||||
|
||||
const AUTH = '/api/auth';
|
||||
|
||||
export const authApi = {
|
||||
login(body: LoginRequest) {
|
||||
return api.post<TokenResponse>(`${AUTH}/login`, {
|
||||
body,
|
||||
skipAuth: true,
|
||||
});
|
||||
},
|
||||
|
||||
loginWithSms(body: SmsLoginRequest) {
|
||||
return api.post<TokenResponse>(`${AUTH}/login/sms`, {
|
||||
body,
|
||||
skipAuth: true,
|
||||
});
|
||||
},
|
||||
|
||||
register(body: RegisterRequest) {
|
||||
return api.post<TokenResponse>(`${AUTH}/register`, {
|
||||
body,
|
||||
skipAuth: true,
|
||||
});
|
||||
},
|
||||
|
||||
registerWithSms(body: SmsRegisterRequest) {
|
||||
return api.post<TokenResponse>(`${AUTH}/register/sms`, {
|
||||
body,
|
||||
skipAuth: true,
|
||||
});
|
||||
},
|
||||
|
||||
sendSmsCode(body: SmsRequest) {
|
||||
return api.post<{ message: string; expires_in: number }>(
|
||||
`${AUTH}/sms/send`,
|
||||
{ body, skipAuth: true },
|
||||
);
|
||||
},
|
||||
|
||||
fetchMe() {
|
||||
return api.get<UserInfo>(`${AUTH}/me`, {
|
||||
timeoutMs: 8_000, // 较短超时,网络不可达时快速失败,不阻塞应用打开
|
||||
});
|
||||
},
|
||||
|
||||
logout(refreshToken: string) {
|
||||
return api.post<{ message: string }>(`${AUTH}/logout`, {
|
||||
body: { refresh_token: refreshToken },
|
||||
});
|
||||
},
|
||||
|
||||
logoutAll() {
|
||||
return api.post<{ message: string }>(`${AUTH}/logout/all`);
|
||||
},
|
||||
|
||||
resetPassword(body: ResetPasswordRequest) {
|
||||
return api.post<{ message: string }>(`${AUTH}/password/reset`, {
|
||||
body,
|
||||
skipAuth: true,
|
||||
});
|
||||
},
|
||||
|
||||
changePassword(body: ChangePasswordRequest) {
|
||||
return api.post<{ message: string }>(`${AUTH}/password/change`, { body });
|
||||
},
|
||||
|
||||
changePhone(body: ChangePhoneRequest) {
|
||||
return api.post<UserInfo>(`${AUTH}/phone/change`, { body });
|
||||
},
|
||||
|
||||
updateNickname(body: UpdateNicknameRequest) {
|
||||
return api.put<UserInfo>(`${AUTH}/me/nickname`, { body });
|
||||
},
|
||||
|
||||
uploadAvatar(file: FormData) {
|
||||
return api.post<UserInfo>(`${AUTH}/me/avatar`, { body: file });
|
||||
},
|
||||
} as const;
|
||||
194
app-expo/src/features/auth/hooks.ts
Normal file
194
app-expo/src/features/auth/hooks.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { router } from 'expo-router';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { AuthError } from '@/core/api/types';
|
||||
import { tokenManager } from '@/core/auth/token-manager';
|
||||
|
||||
import { authApi } from './api';
|
||||
import type {
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
SessionState,
|
||||
SmsLoginRequest,
|
||||
SmsRegisterRequest,
|
||||
SmsRequest,
|
||||
TokenResponse,
|
||||
UserInfo,
|
||||
} from './types';
|
||||
|
||||
// ─── Query keys ───
|
||||
|
||||
export const authKeys = {
|
||||
session: ['session'] as const,
|
||||
tokenCheck: ['auth', 'token-check'] as const,
|
||||
};
|
||||
|
||||
// ─── useSession ───
|
||||
|
||||
/**
|
||||
* Bootstrap flow:
|
||||
* 1. Async check whether tokens exist in secure store
|
||||
* 2. If tokens exist → call GET /api/auth/me
|
||||
* 3. Derive 4-state status from the two queries
|
||||
*
|
||||
* All consumers share the same TanStack Query cache — no Context needed.
|
||||
*/
|
||||
export function useSession(): SessionState {
|
||||
const tokenCheck = useQuery({
|
||||
queryKey: authKeys.tokenCheck,
|
||||
queryFn: () => tokenManager.hasTokens(),
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const session = useQuery<UserInfo>({
|
||||
queryKey: authKeys.session,
|
||||
queryFn: () => authApi.fetchMe(),
|
||||
enabled: tokenCheck.data === true,
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof AuthError) return false;
|
||||
return failureCount < 1;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (tokenCheck.isLoading) {
|
||||
return { status: 'loading', user: null, isAuthenticated: false };
|
||||
}
|
||||
|
||||
if (tokenCheck.error) {
|
||||
return { status: 'error', user: null, isAuthenticated: false };
|
||||
}
|
||||
|
||||
if (!tokenCheck.data) {
|
||||
return { status: 'unauthenticated', user: null, isAuthenticated: false };
|
||||
}
|
||||
|
||||
// tokenCheck.data === true from here — tokens exist, fetch /me in background
|
||||
// 乐观认证:有 token 即展示应用,不阻塞 session 请求;真正需要联网的页面自行处理 loading
|
||||
if (session.error) {
|
||||
if (session.error instanceof AuthError) {
|
||||
return { status: 'unauthenticated', user: null, isAuthenticated: false };
|
||||
}
|
||||
// 网络错误等:仍视为已认证,user 为 null,各页面按需展示 loading/错误态
|
||||
return {
|
||||
status: 'authenticated',
|
||||
user: null,
|
||||
isAuthenticated: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (session.data) {
|
||||
return {
|
||||
status: 'authenticated',
|
||||
user: session.data,
|
||||
isAuthenticated: true,
|
||||
};
|
||||
}
|
||||
|
||||
// session 仍在 loading 或未就绪:乐观展示应用,user 为 null
|
||||
return {
|
||||
status: 'authenticated',
|
||||
user: null,
|
||||
isAuthenticated: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Shared post-auth helper ───
|
||||
|
||||
function usePostAuthSetup() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useCallback(
|
||||
async (tokens: TokenResponse) => {
|
||||
await tokenManager.setTokens(tokens.access_token, tokens.refresh_token);
|
||||
queryClient.setQueryData(authKeys.tokenCheck, true);
|
||||
await queryClient.invalidateQueries({ queryKey: authKeys.session });
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
}
|
||||
|
||||
// ─── useLogin ───
|
||||
|
||||
export function useLogin() {
|
||||
const onSuccess = usePostAuthSetup();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: LoginRequest) => authApi.login(body),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── useSmsLogin ───
|
||||
|
||||
export function useSmsLogin() {
|
||||
const onSuccess = usePostAuthSetup();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: SmsLoginRequest) => authApi.loginWithSms(body),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── useRegister ───
|
||||
|
||||
export function useRegister() {
|
||||
const onSuccess = usePostAuthSetup();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: RegisterRequest) => authApi.register(body),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── useSmsRegister ───
|
||||
|
||||
export function useSmsRegister() {
|
||||
const onSuccess = usePostAuthSetup();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: SmsRegisterRequest) => authApi.registerWithSms(body),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── useSmsCode ───
|
||||
|
||||
export function useSmsCode() {
|
||||
return useMutation({
|
||||
mutationFn: (body: SmsRequest) => authApi.sendSmsCode(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── useLogout ───
|
||||
|
||||
/**
|
||||
* Logout sequence:
|
||||
* 1. Read refresh token BEFORE clearing
|
||||
* 2. Call POST /api/auth/logout (best effort — may fail if offline)
|
||||
* 3. Always clear local tokens + query cache in finally
|
||||
*/
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await authApi.logout(refreshToken);
|
||||
} catch {
|
||||
// Best effort — server-side revocation may fail, but local cleanup proceeds.
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await tokenManager.clearTokens();
|
||||
queryClient.clear();
|
||||
queryClient.setQueryData(authKeys.tokenCheck, false);
|
||||
router.replace('/(auth)/login');
|
||||
},
|
||||
});
|
||||
}
|
||||
94
app-expo/src/features/auth/types.ts
Normal file
94
app-expo/src/features/auth/types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// ─── Response types ───
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
phone: string;
|
||||
email: string | null;
|
||||
nickname: string;
|
||||
avatar_url: string | null;
|
||||
subscription_type: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ─── Request types ───
|
||||
|
||||
export interface LoginRequest {
|
||||
phone: string;
|
||||
password: string;
|
||||
agreed_to_terms: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
phone: string;
|
||||
password: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
agreed_to_terms: boolean;
|
||||
}
|
||||
|
||||
export type SmsPurpose =
|
||||
| 'register'
|
||||
| 'login'
|
||||
| 'reset_password'
|
||||
| 'change_phone';
|
||||
|
||||
export interface SmsRequest {
|
||||
phone: string;
|
||||
purpose: SmsPurpose;
|
||||
}
|
||||
|
||||
export interface SmsLoginRequest {
|
||||
phone: string;
|
||||
code: string;
|
||||
agreed_to_terms: boolean;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export interface SmsRegisterRequest {
|
||||
phone: string;
|
||||
code: string;
|
||||
password: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
agreed_to_terms: boolean;
|
||||
}
|
||||
|
||||
export interface ResetPasswordRequest {
|
||||
phone: string;
|
||||
code: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
export interface ChangePhoneRequest {
|
||||
new_phone: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface UpdateNicknameRequest {
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
// ─── Session state ───
|
||||
|
||||
export type SessionStatus =
|
||||
| 'loading'
|
||||
| 'authenticated'
|
||||
| 'unauthenticated'
|
||||
| 'error';
|
||||
|
||||
export interface SessionState {
|
||||
status: SessionStatus;
|
||||
user: UserInfo | null;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user