Squash merge feat/expo-app: app-expo, .cursor, workflows, package.json, .husky; remove app-android, app-ios, react-app

This commit is contained in:
Kevin
2026-03-19 01:12:17 +08:00
parent 9e4f301ab9
commit b4f4369b7d
544 changed files with 23707 additions and 67151 deletions

View 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;

View 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');
},
});
}

View 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;
}