用户静默注册
This commit is contained in:
@@ -17,9 +17,11 @@ import RedeemPage from './pages/RedeemPage';
|
|||||||
import RedeemCodePage from './pages/RedeemCodePage';
|
import RedeemCodePage from './pages/RedeemCodePage';
|
||||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||||
import PayPage from './pages/PayPage';
|
import PayPage from './pages/PayPage';
|
||||||
|
import { UserSessionProvider } from './contexts/UserSessionContext';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
<UserSessionProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route element={<TabLayout />}>
|
<Route element={<TabLayout />}>
|
||||||
@@ -43,5 +45,6 @@ export default function App() {
|
|||||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</UserSessionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { bindPhone, request, saveSession } from '../lib/api';
|
||||||
|
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||||
|
|
||||||
|
type PhoneVerifySheetProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PhoneVerifySheet({ open, onClose, onSuccess }: PhoneVerifySheetProps) {
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setPhone('');
|
||||||
|
setCode('');
|
||||||
|
setMsg('');
|
||||||
|
setCodeCooldown(0);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function sendCode() {
|
||||||
|
const phoneCheck = validateMobilePhone(phone);
|
||||||
|
if (!phoneCheck.ok) {
|
||||||
|
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMsg('');
|
||||||
|
await request('USER_H5', '/auth/sms/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone, scene: 'BIND_PHONE' }),
|
||||||
|
});
|
||||||
|
setMsg('验证码已发送(Mock: 123456)');
|
||||||
|
setCodeCooldown(60);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCodeCooldown((c) => {
|
||||||
|
if (c <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return c - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const phoneCheck = validateMobilePhone(phone);
|
||||||
|
if (!phoneCheck.ok) {
|
||||||
|
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!code.trim()) {
|
||||||
|
setMsg('请输入验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setMsg('');
|
||||||
|
try {
|
||||||
|
const session = await bindPhone(phone, code);
|
||||||
|
saveSession(session);
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '验证失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="phone-verify-overlay" role="dialog" aria-modal="true">
|
||||||
|
<button type="button" className="phone-verify-backdrop" aria-label="关闭" onClick={onClose} />
|
||||||
|
<div className="phone-verify-sheet">
|
||||||
|
<h3 className="phone-verify-title">验证手机号</h3>
|
||||||
|
<p className="phone-verify-desc">下单前需验证手机号,以便接收订单通知</p>
|
||||||
|
<div className="login-field">
|
||||||
|
<span className="login-field-prefix">+86</span>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
className="login-field-input"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
maxLength={11}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPhone(normalizePhoneInput(e.target.value));
|
||||||
|
setMsg('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="login-field">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="login-field-input"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`login-get-code${codeCooldown > 0 ? ' disabled' : ''}`}
|
||||||
|
disabled={codeCooldown > 0}
|
||||||
|
onClick={sendCode}
|
||||||
|
>
|
||||||
|
{codeCooldown > 0 ? `${codeCooldown}s 后重新获取` : '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{msg && <p className="login-msg">{msg}</p>}
|
||||||
|
<button type="button" className="login-sms-btn" disabled={loading} onClick={submit}>
|
||||||
|
{loading ? '验证中...' : '确认验证'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
bootstrapSession,
|
||||||
|
clearAuth,
|
||||||
|
ensureSession,
|
||||||
|
getDeviceKey,
|
||||||
|
request,
|
||||||
|
type SessionPayload,
|
||||||
|
type UserProfile,
|
||||||
|
} from '../lib/api';
|
||||||
|
|
||||||
|
type UserSessionContextValue = {
|
||||||
|
ready: boolean;
|
||||||
|
profile: UserProfile | null;
|
||||||
|
phoneVerified: boolean;
|
||||||
|
refreshProfile: () => Promise<void>;
|
||||||
|
resetSession: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserSessionContext = createContext<UserSessionContextValue | null>(null);
|
||||||
|
|
||||||
|
export function UserSessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [phoneVerified, setPhoneVerified] = useState(false);
|
||||||
|
|
||||||
|
const applySession = useCallback((session: SessionPayload) => {
|
||||||
|
if (session.user) setProfile(session.user);
|
||||||
|
setPhoneVerified(!!session.phoneVerified || !!session.user?.phoneVerified);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshProfile = useCallback(async () => {
|
||||||
|
const me = await request<UserProfile>('USER_H5', '/auth/me');
|
||||||
|
setProfile(me);
|
||||||
|
setPhoneVerified(!!me.phoneVerified);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetSession = useCallback(async () => {
|
||||||
|
clearAuth();
|
||||||
|
const session = await bootstrapSession();
|
||||||
|
applySession(session);
|
||||||
|
}, [applySession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const session = await ensureSession();
|
||||||
|
if (cancelled) return;
|
||||||
|
applySession(session);
|
||||||
|
if (!session.user) {
|
||||||
|
await refreshProfile();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
try {
|
||||||
|
const session = await bootstrapSession();
|
||||||
|
applySession(session);
|
||||||
|
await refreshProfile();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [applySession, refreshProfile]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
ready,
|
||||||
|
profile,
|
||||||
|
phoneVerified,
|
||||||
|
refreshProfile,
|
||||||
|
resetSession,
|
||||||
|
}),
|
||||||
|
[ready, profile, phoneVerified, refreshProfile, resetSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="session-boot">
|
||||||
|
<p className="session-boot-text">加载中...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <UserSessionContext.Provider value={value}>{children}</UserSessionContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserSession() {
|
||||||
|
const ctx = useContext(UserSessionContext);
|
||||||
|
if (!ctx) throw new Error('useUserSession must be used within UserSessionProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOptionalUserSession() {
|
||||||
|
return useContext(UserSessionContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated use profile from useUserSession */
|
||||||
|
export { getDeviceKey };
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import { isLoggedIn } from '../lib/api';
|
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ to: '/', end: true, icon: 'home', label: '首页', fillActive: false },
|
{ to: '/', end: true, icon: 'home', label: '首页', fillActive: false },
|
||||||
@@ -9,11 +8,6 @@ const TABS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const navigate = useNavigate();
|
|
||||||
if (!isLoggedIn()) {
|
|
||||||
navigate('/login');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
+134
-22
@@ -7,36 +7,148 @@ export const BRAND = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const apiBase = '/api/v1';
|
export const apiBase = '/api/v1';
|
||||||
|
const CLIENT_APP = 'USER_H5';
|
||||||
|
|
||||||
export async function request<T>(
|
export type UserProfile = {
|
||||||
clientApp: string,
|
id: string;
|
||||||
path: string,
|
userNo: string;
|
||||||
options: RequestInit = {},
|
phone: string | null;
|
||||||
): Promise<T> {
|
phoneVerified: boolean;
|
||||||
const token = localStorage.getItem('accessToken');
|
nickname: string | null;
|
||||||
const headers: Record<string, string> = {
|
avatarUrl: string | null;
|
||||||
'Content-Type': 'application/json',
|
hasWechat: boolean;
|
||||||
'X-Client-App': clientApp,
|
};
|
||||||
...(options.headers as Record<string, string>),
|
|
||||||
};
|
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
|
||||||
|
|
||||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
export type SessionPayload = {
|
||||||
const json = await res.json();
|
accessToken: string;
|
||||||
if (json.code !== 0) throw new Error(json.message || '请求失败');
|
refreshToken: string;
|
||||||
return json.data as T;
|
deviceKey?: string;
|
||||||
|
phoneVerified: boolean;
|
||||||
|
user?: UserProfile;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEVICE_KEY = 'deviceKey';
|
||||||
|
const ACCESS_TOKEN = 'accessToken';
|
||||||
|
const REFRESH_TOKEN = 'refreshToken';
|
||||||
|
|
||||||
|
export function getDeviceKey() {
|
||||||
|
return localStorage.getItem(DEVICE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveAuth(data: { accessToken: string; refreshToken?: string }) {
|
export function saveSession(data: SessionPayload) {
|
||||||
localStorage.setItem('accessToken', data.accessToken);
|
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||||
if (data.refreshToken) localStorage.setItem('refreshToken', data.refreshToken);
|
localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||||
|
if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAuth(data: { accessToken: string; refreshToken?: string; deviceKey?: string }) {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||||
|
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||||
|
if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuth() {
|
export function clearAuth() {
|
||||||
localStorage.removeItem('accessToken');
|
localStorage.removeItem(ACCESS_TOKEN);
|
||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem(REFRESH_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLoggedIn() {
|
export function isLoggedIn() {
|
||||||
return !!localStorage.getItem('accessToken');
|
return !!localStorage.getItem(ACCESS_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rawRequest<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
token?: string | null,
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-App': CLIENT_APP,
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
const authToken = token ?? localStorage.getItem(ACCESS_TOKEN);
|
||||||
|
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) {
|
||||||
|
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||||
|
err.status = json.code;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return json.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(
|
||||||
|
_clientApp: string,
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
return rawRequest<T>(path, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapSession(): Promise<SessionPayload> {
|
||||||
|
const deviceKey = getDeviceKey();
|
||||||
|
const data = await rawRequest<SessionPayload>(
|
||||||
|
'/auth/session/bootstrap',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(deviceKey ? { deviceKey } : {}),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
saveSession(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshSession(): Promise<SessionPayload | null> {
|
||||||
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
||||||
|
if (!refreshToken) return null;
|
||||||
|
try {
|
||||||
|
const data = await rawRequest<SessionPayload>(
|
||||||
|
'/auth/token/refresh',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
saveSession(data);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSession(): Promise<SessionPayload> {
|
||||||
|
if (isLoggedIn()) {
|
||||||
|
try {
|
||||||
|
const me = await rawRequest<UserProfile>('/auth/me');
|
||||||
|
return {
|
||||||
|
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||||
|
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||||
|
deviceKey: getDeviceKey() ?? undefined,
|
||||||
|
phoneVerified: !!me.phoneVerified,
|
||||||
|
user: me,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error & { status?: number };
|
||||||
|
if (err.status === 401) {
|
||||||
|
clearAuth();
|
||||||
|
} else {
|
||||||
|
const refreshed = await refreshSession();
|
||||||
|
if (refreshed) return refreshed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bootstrapSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bindPhone(phone: string, code: string): Promise<SessionPayload> {
|
||||||
|
const data = await rawRequest<SessionPayload>('/auth/phone/bind', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone, code }),
|
||||||
|
});
|
||||||
|
saveSession(data);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { request, saveAuth } from '../lib/api';
|
import { request, saveSession } from '../lib/api';
|
||||||
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@@ -60,11 +60,15 @@ export default function LoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
const data = await request<{ accessToken: string; refreshToken: string }>('USER_H5', '/auth/login/sms', {
|
const data = await request<{
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
deviceKey?: string;
|
||||||
|
}>('USER_H5', '/auth/login/sms', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, code }),
|
body: JSON.stringify({ phone, code }),
|
||||||
});
|
});
|
||||||
saveAuth(data);
|
saveSession(data);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import TabMainHeader from '../components/TabMainHeader';
|
import TabMainHeader from '../components/TabMainHeader';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { clearAuth, request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { useUserSession } from '../contexts/UserSessionContext';
|
||||||
|
|
||||||
const DEFAULT_AVATAR =
|
const DEFAULT_AVATAR =
|
||||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuAz_9Pnpk_Md4sEU6PXkeybus8oLZO9e-3pOpLuSwBX0jm_Z0JCfX1w2oZxz1VZayTh0PKUPjwjSuxJVX410fjtWFGR_f55f-nWppXWUweHRnEC7WyIWEqx4AyVHt-k02OhyaSGQfvY5cHG5IuRe9EqdcHy47gBQ82_cxGgX-DrKV4oYcwLoNRynAV0_xv2p1GOhisnQVulHwZcQClUJcP8q4nTY0Y3DR1w4ioa0DYTHePE43mLDJptjZcQqS7V8LihJdn4ze6fvQA';
|
'https://lh3.googleusercontent.com/aida-public/AB6AXuAz_9Pnpk_Md4sEU6PXkeybus8oLZO9e-3pOpLuSwBX0jm_Z0JCfX1w2oZxz1VZayTh0PKUPjwjSuxJVX410fjtWFGR_f55f-nWppXWUweHRnEC7WyIWEqx4AyVHt-k02OhyaSGQfvY5cHG5IuRe9EqdcHy47gBQ82_cxGgX-DrKV4oYcwLoNRynAV0_xv2p1GOhisnQVulHwZcQClUJcP8q4nTY0Y3DR1w4ioa0DYTHePE43mLDJptjZcQqS7V8LihJdn4ze6fvQA';
|
||||||
@@ -27,7 +28,10 @@ function formatMoney(amount: number) {
|
|||||||
|
|
||||||
export default function MinePage() {
|
export default function MinePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
|
const { profile: sessionProfile, resetSession } = useUserSession();
|
||||||
|
const [profile, setProfile] = useState<Record<string, unknown> | null>(
|
||||||
|
sessionProfile as Record<string, unknown> | null,
|
||||||
|
);
|
||||||
const [benefitBalance, setBenefitBalance] = useState(0);
|
const [benefitBalance, setBenefitBalance] = useState(0);
|
||||||
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
||||||
const [toast, setToast] = useState('');
|
const [toast, setToast] = useState('');
|
||||||
@@ -68,8 +72,7 @@ export default function MinePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
clearAuth();
|
void resetSession().then(() => navigate('/'));
|
||||||
navigate('/login');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nickname = String(profile?.nickname || '用户');
|
const nickname = String(profile?.nickname || '用户');
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { request } from '../lib/api';
|
|||||||
import { buildProductDetailUrl } from '../lib/navigation';
|
import { buildProductDetailUrl } from '../lib/navigation';
|
||||||
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
||||||
import { tryGetClientGpsLocation } from '../lib/client-location';
|
import { tryGetClientGpsLocation } from '../lib/client-location';
|
||||||
|
import { getProductMainImage } from '../lib/product-images';
|
||||||
|
import PhoneVerifySheet from '../components/PhoneVerifySheet';
|
||||||
|
import { useUserSession } from '../contexts/UserSessionContext';
|
||||||
|
|
||||||
type Address = {
|
type Address = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,6 +51,9 @@ function formatAddress(a: Address) {
|
|||||||
export default function OrderConfirmPage() {
|
export default function OrderConfirmPage() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { phoneVerified, refreshProfile } = useUserSession();
|
||||||
|
const [showPhoneVerify, setShowPhoneVerify] = useState(false);
|
||||||
|
const [pendingSubmit, setPendingSubmit] = useState(false);
|
||||||
const productId = params.get('productId') || '';
|
const productId = params.get('productId') || '';
|
||||||
const forceCross = params.get('cross') === '1';
|
const forceCross = params.get('cross') === '1';
|
||||||
const [quantity, setQuantity] = useState(Number(params.get('qty') || 2));
|
const [quantity, setQuantity] = useState(Number(params.get('qty') || 2));
|
||||||
@@ -111,31 +117,55 @@ export default function OrderConfirmPage() {
|
|||||||
const productImage =
|
const productImage =
|
||||||
productIndex === 0 ? STITCH_ORDER_PRODUCT_IMAGE : getProductMainImage(productIndex);
|
productIndex === 0 ? STITCH_ORDER_PRODUCT_IMAGE : getProductMainImage(productIndex);
|
||||||
|
|
||||||
|
async function doSubmit() {
|
||||||
|
const clientLocation = await tryGetClientGpsLocation();
|
||||||
|
const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
productId,
|
||||||
|
quantity,
|
||||||
|
addressId,
|
||||||
|
...(clientLocation ? { clientLocation } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('orderId', order.id);
|
||||||
|
qs.set('productId', productId);
|
||||||
|
qs.set('qty', String(quantity));
|
||||||
|
qs.set('addressId', addressId);
|
||||||
|
if (forceCross) qs.set('cross', '1');
|
||||||
|
navigate(`/pay?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!addressId) {
|
if (!addressId) {
|
||||||
setMsg('请选择收货地址');
|
setMsg('请选择收货地址');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!phoneVerified) {
|
||||||
|
setPendingSubmit(true);
|
||||||
|
setShowPhoneVerify(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
const clientLocation = await tryGetClientGpsLocation();
|
await doSubmit();
|
||||||
const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
|
} catch (e) {
|
||||||
method: 'POST',
|
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||||
body: JSON.stringify({
|
} finally {
|
||||||
productId,
|
setLoading(false);
|
||||||
quantity,
|
}
|
||||||
addressId,
|
}
|
||||||
...(clientLocation ? { clientLocation } : {}),
|
|
||||||
}),
|
async function handlePhoneVerified() {
|
||||||
});
|
await refreshProfile();
|
||||||
const qs = new URLSearchParams();
|
if (!pendingSubmit) return;
|
||||||
qs.set('orderId', order.id);
|
setPendingSubmit(false);
|
||||||
qs.set('productId', productId);
|
setLoading(true);
|
||||||
qs.set('qty', String(quantity));
|
setMsg('');
|
||||||
qs.set('addressId', addressId);
|
try {
|
||||||
if (forceCross) qs.set('cross', '1');
|
await doSubmit();
|
||||||
navigate(`/pay?${qs.toString()}`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -296,6 +326,15 @@ export default function OrderConfirmPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<PhoneVerifySheet
|
||||||
|
open={showPhoneVerify}
|
||||||
|
onClose={() => {
|
||||||
|
setShowPhoneVerify(false);
|
||||||
|
setPendingSubmit(false);
|
||||||
|
}}
|
||||||
|
onSuccess={handlePhoneVerified}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5449,3 +5449,59 @@
|
|||||||
color: var(--color-on-surface-variant);
|
color: var(--color-on-surface-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 会话启动 / 手机验证 ── */
|
||||||
|
.session-boot {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-boot-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-on-surface-variant, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-verify-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-verify-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-verify-sheet {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
padding: 24px 20px calc(24px + env(safe-area-inset-bottom));
|
||||||
|
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-verify-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-on-surface, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-verify-desc {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-on-surface-variant, #666);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,17 +206,22 @@ model WxAppConfig {
|
|||||||
// ─── C端用户(phone 唯一主键,微信字段辅助)──────────
|
// ─── C端用户(phone 唯一主键,微信字段辅助)──────────
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
userNo String @unique @map("user_no") @db.VarChar(20)
|
userNo String @unique @map("user_no") @db.VarChar(20)
|
||||||
phone String @unique @db.VarChar(20)
|
deviceKey String? @unique @map("device_key") @db.VarChar(36)
|
||||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
phone String? @unique @db.VarChar(20)
|
||||||
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3)
|
||||||
nickname String? @db.VarChar(64)
|
mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt
|
||||||
avatarUrl String? @map("avatar_url") @db.VarChar(512)
|
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||||
status Int @default(1) @db.TinyInt
|
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
nickname String? @db.VarChar(64)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
avatarUrl String? @map("avatar_url") @db.VarChar(512)
|
||||||
|
status Int @default(1) @db.TinyInt
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
mergedInto User? @relation("UserMerge", fields: [mergedIntoUserId], references: [id], onDelete: SetNull)
|
||||||
|
mergedFrom User[] @relation("UserMerge")
|
||||||
addresses UserAddress[]
|
addresses UserAddress[]
|
||||||
cityPref UserCityPreference?
|
cityPref UserCityPreference?
|
||||||
orders Order[]
|
orders Order[]
|
||||||
@@ -228,6 +233,7 @@ model User {
|
|||||||
|
|
||||||
@@index([wxOpenId])
|
@@index([wxOpenId])
|
||||||
@@index([wxUnionId])
|
@@index([wxUnionId])
|
||||||
|
@@index([mergedIntoUserId])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface AuthUser {
|
|||||||
actorId: bigint;
|
actorId: bigint;
|
||||||
clientApp: ClientApp;
|
clientApp: ClientApp;
|
||||||
sub: string;
|
sub: string;
|
||||||
|
phoneVerified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -39,6 +40,7 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
actorId: BigInt(payload.actorId),
|
actorId: BigInt(payload.actorId),
|
||||||
clientApp,
|
clientApp,
|
||||||
sub: payload.sub,
|
sub: payload.sub,
|
||||||
|
phoneVerified: !!payload.phoneVerified,
|
||||||
} satisfies AuthUser;
|
} satisfies AuthUser;
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
|
||||||
|
import type { AuthUser } from './jwt-auth.guard';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OptionalJwtAuthGuard implements CanActivate {
|
||||||
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const auth = req.headers.authorization as string | undefined;
|
||||||
|
if (!auth?.startsWith('Bearer ')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = this.jwtService.verify(auth.slice(7));
|
||||||
|
const clientApp = req.headers['x-client-app'] as ClientApp;
|
||||||
|
if (!clientApp || payload.clientApp !== clientApp) return true;
|
||||||
|
const expectedActor = CLIENT_APP_ACTOR_MAP[clientApp];
|
||||||
|
if (payload.actorType !== expectedActor) return true;
|
||||||
|
req.user = {
|
||||||
|
actorType: payload.actorType,
|
||||||
|
actorId: BigInt(payload.actorId),
|
||||||
|
clientApp,
|
||||||
|
sub: payload.sub,
|
||||||
|
phoneVerified: !!payload.phoneVerified,
|
||||||
|
} satisfies AuthUser;
|
||||||
|
} catch {
|
||||||
|
/* ignore invalid token */
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.module';
|
||||||
|
import type { AuthUser } from './jwt-auth.guard';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PhoneVerifiedGuard implements CanActivate {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const user = req.user as AuthUser | undefined;
|
||||||
|
if (!user || user.actorType !== 'USER') {
|
||||||
|
throw new ForbiddenException('请先验证手机号');
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await this.prisma.user.findUnique({
|
||||||
|
where: { id: user.actorId },
|
||||||
|
select: { phoneVerifiedAt: true, mergedIntoUserId: true, status: true },
|
||||||
|
});
|
||||||
|
if (!row || row.status !== 1 || row.mergedIntoUserId) {
|
||||||
|
throw new ForbiddenException('账号状态异常,请重新进入');
|
||||||
|
}
|
||||||
|
if (!row.phoneVerifiedAt) {
|
||||||
|
throw new ForbiddenException('请先验证手机号');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LoginSmsDto, SendSmsDto } from './dto/auth.dto';
|
import {
|
||||||
|
BindPhoneDto,
|
||||||
|
BootstrapSessionDto,
|
||||||
|
LoginSmsDto,
|
||||||
|
RefreshTokenDto,
|
||||||
|
SendSmsDto,
|
||||||
|
} from './dto/auth.dto';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
import { ClientApp } from '@dukang/shared-types';
|
import { ClientApp } from '@dukang/shared-types';
|
||||||
@@ -10,14 +18,33 @@ import { ClientApp } from '@dukang/shared-types';
|
|||||||
export class UserAuthController {
|
export class UserAuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('auth/session/bootstrap')
|
||||||
|
bootstrap(@Body() dto: BootstrapSessionDto) {
|
||||||
|
return this.authService.bootstrapSession(dto.deviceKey, ClientApp.USER_H5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('auth/token/refresh')
|
||||||
|
refresh(@Body() dto: RefreshTokenDto) {
|
||||||
|
return this.authService.refreshAccessToken(dto.refreshToken, ClientApp.USER_H5);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('auth/sms/send')
|
@Post('auth/sms/send')
|
||||||
sendSms(@Body() dto: SendSmsDto) {
|
sendSms(@Body() dto: SendSmsDto) {
|
||||||
return this.authService.sendSms(dto.phone, dto.scene);
|
return this.authService.sendSms(dto.phone, dto.scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('auth/login/sms')
|
@Post('auth/login/sms')
|
||||||
login(@Body() dto: LoginSmsDto) {
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
return this.authService.loginUser(dto.phone, dto.code, ClientApp.USER_H5);
|
login(@Req() req: Request, @Body() dto: LoginSmsDto) {
|
||||||
|
const guest = (req as Request & { user?: AuthUser }).user;
|
||||||
|
const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
|
||||||
|
return this.authService.loginUser(dto.phone, dto.code, ClientApp.USER_H5, guestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('auth/phone/bind')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
bindPhone(@CurrentUser() user: AuthUser, @Body() dto: BindPhoneDto) {
|
||||||
|
return this.authService.bindPhone(user.actorId, dto.phone, dto.code, ClientApp.USER_H5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('auth/login/wechat')
|
@Post('auth/login/wechat')
|
||||||
@@ -26,7 +53,7 @@ export class UserAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('auth/wechat/bind-phone')
|
@Post('auth/wechat/bind-phone')
|
||||||
bindPhone() {
|
bindPhoneLegacy() {
|
||||||
return this.authService.wechatDisabled();
|
return this.authService.wechatDisabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
NotImplementedException,
|
NotImplementedException,
|
||||||
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ClientApp, SmsScene } from '@dukang/shared-types';
|
import { ClientApp, SmsScene } from '@dukang/shared-types';
|
||||||
@@ -11,6 +15,23 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
|||||||
import { SMS_PROVIDER } from '../../integrations/integrations.constants';
|
import { SMS_PROVIDER } from '../../integrations/integrations.constants';
|
||||||
import { ISmsProvider } from '../../integrations/sms/sms.interface';
|
import { ISmsProvider } from '../../integrations/sms/sms.interface';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import type { User } from '@prisma/client';
|
||||||
|
|
||||||
|
type UserRow = Pick<
|
||||||
|
User,
|
||||||
|
| 'id'
|
||||||
|
| 'userNo'
|
||||||
|
| 'deviceKey'
|
||||||
|
| 'phone'
|
||||||
|
| 'phoneVerifiedAt'
|
||||||
|
| 'nickname'
|
||||||
|
| 'avatarUrl'
|
||||||
|
| 'wxOpenId'
|
||||||
|
| 'mergedIntoUserId'
|
||||||
|
| 'status'
|
||||||
|
>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -25,28 +46,144 @@ export class AuthService {
|
|||||||
return { sent: true };
|
return { sent: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginUser(phone: string, code: string, clientApp: ClientApp) {
|
async bootstrapSession(deviceKey: string | undefined, clientApp: ClientApp) {
|
||||||
await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN);
|
let user: UserRow | null = null;
|
||||||
let user = await this.prisma.user.findUnique({ where: { phone } });
|
let resolvedDeviceKey = deviceKey?.trim() || null;
|
||||||
if (!user) {
|
|
||||||
user = await this.prisma.user.create({
|
if (resolvedDeviceKey) {
|
||||||
data: {
|
user = await this.prisma.user.findFirst({
|
||||||
phone,
|
where: {
|
||||||
userNo: generateUserNo(),
|
deviceKey: resolvedDeviceKey,
|
||||||
nickname: `用户${phone.slice(-4)}`,
|
status: 1,
|
||||||
|
mergedIntoUserId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.prisma.userCityPreference.create({
|
}
|
||||||
data: { userId: user.id, selectedCityCode: '410100', selectedDistrict: '郑州市' },
|
|
||||||
|
if (!user) {
|
||||||
|
resolvedDeviceKey = randomUUID();
|
||||||
|
user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
userNo: generateUserNo(),
|
||||||
|
deviceKey: resolvedDeviceKey,
|
||||||
|
nickname: '访客',
|
||||||
|
cityPref: {
|
||||||
|
create: {
|
||||||
|
selectedCityCode: '410100',
|
||||||
|
selectedDistrict: '郑州市',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return this.issueToken('USER', user.id, clientApp, {
|
|
||||||
id: user.id.toString(),
|
return this.buildSessionResponse(user, clientApp, resolvedDeviceKey);
|
||||||
userNo: user.userNo,
|
}
|
||||||
phone: user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
|
|
||||||
nickname: user.nickname,
|
async refreshAccessToken(refreshToken: string, clientApp: ClientApp) {
|
||||||
hasWechat: !!user.wxOpenId,
|
try {
|
||||||
});
|
const payload = this.jwtService.verify(refreshToken);
|
||||||
|
if (payload.clientApp !== clientApp || payload.actorType !== 'USER') {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
const user = await this.assertActiveUser(BigInt(payload.actorId));
|
||||||
|
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedException) throw err;
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||||
|
await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN);
|
||||||
|
let user: UserRow | null = await this.prisma.user.findUnique({ where: { phone } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
if (guestId) {
|
||||||
|
try {
|
||||||
|
const guest = await this.assertActiveUser(guestId);
|
||||||
|
if (!guest.phone) {
|
||||||
|
user = await this.prisma.user.update({
|
||||||
|
where: { id: guestId },
|
||||||
|
data: {
|
||||||
|
phone,
|
||||||
|
phoneVerifiedAt: new Date(),
|
||||||
|
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* guest invalid, fall through to create */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
phone,
|
||||||
|
phoneVerifiedAt: new Date(),
|
||||||
|
userNo: generateUserNo(),
|
||||||
|
nickname: `用户${phone.slice(-4)}`,
|
||||||
|
cityPref: {
|
||||||
|
create: {
|
||||||
|
selectedCityCode: '410100',
|
||||||
|
selectedDistrict: '郑州市',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!user.phoneVerifiedAt) {
|
||||||
|
user = await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { phoneVerifiedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (guestId && guestId !== user.id) {
|
||||||
|
user = await this.mergeUsers(guestId, user.id);
|
||||||
|
} else {
|
||||||
|
await this.assertActiveUser(user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) throw new BadRequestException('登录失败');
|
||||||
|
|
||||||
|
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) {
|
||||||
|
await this.smsProvider.verify(phone, code, SmsScene.BIND_PHONE);
|
||||||
|
const guest = await this.assertActiveUser(actorId);
|
||||||
|
|
||||||
|
if (guest.phone && guest.phoneVerifiedAt) {
|
||||||
|
if (guest.phone === phone) {
|
||||||
|
return this.buildSessionResponse(guest, clientApp, guest.deviceKey);
|
||||||
|
}
|
||||||
|
throw new BadRequestException('当前账号已绑定其他手机号');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.prisma.user.findUnique({ where: { phone } });
|
||||||
|
let targetUser: UserRow;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
targetUser = await this.prisma.user.update({
|
||||||
|
where: { id: guest.id },
|
||||||
|
data: {
|
||||||
|
phone,
|
||||||
|
phoneVerifiedAt: new Date(),
|
||||||
|
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.assertActiveUser(existing.id);
|
||||||
|
if (existing.id === guest.id) {
|
||||||
|
targetUser = existing;
|
||||||
|
} else {
|
||||||
|
targetUser = await this.mergeUsers(guest.id, existing.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildSessionResponse(targetUser, clientApp, targetUser.deviceKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginStore(phone: string, code: string, clientApp: ClientApp) {
|
async loginStore(phone: string, code: string, clientApp: ClientApp) {
|
||||||
@@ -60,7 +197,7 @@ export class AuthService {
|
|||||||
where: { id: account.id },
|
where: { id: account.id },
|
||||||
data: { lastLoginAt: new Date() },
|
data: { lastLoginAt: new Date() },
|
||||||
});
|
});
|
||||||
return this.issueToken('STORE', account.id, clientApp, undefined, {
|
return this.issueToken('STORE', account.id, clientApp, false, undefined, {
|
||||||
id: account.id.toString(),
|
id: account.id.toString(),
|
||||||
storeId: account.storeId.toString(),
|
storeId: account.storeId.toString(),
|
||||||
name: account.name,
|
name: account.name,
|
||||||
@@ -80,7 +217,7 @@ export class AuthService {
|
|||||||
where: { id: account.id },
|
where: { id: account.id },
|
||||||
data: { lastLoginAt: new Date() },
|
data: { lastLoginAt: new Date() },
|
||||||
});
|
});
|
||||||
return this.issueToken('PARTNER', account.id, clientApp, undefined, undefined, {
|
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
|
||||||
id: account.id.toString(),
|
id: account.id.toString(),
|
||||||
partnerId: account.partnerId.toString(),
|
partnerId: account.partnerId.toString(),
|
||||||
name: account.name,
|
name: account.name,
|
||||||
@@ -92,8 +229,8 @@ export class AuthService {
|
|||||||
|
|
||||||
async getMe(actorType: string, actorId: bigint) {
|
async getMe(actorType: string, actorId: bigint) {
|
||||||
if (actorType === 'USER') {
|
if (actorType === 'USER') {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: actorId } });
|
const user = await this.assertActiveUser(actorId);
|
||||||
return serializeBigInt(user);
|
return this.formatUserProfile(user);
|
||||||
}
|
}
|
||||||
if (actorType === 'STORE') {
|
if (actorType === 'STORE') {
|
||||||
const account = await this.prisma.storeAccount.findUnique({
|
const account = await this.prisma.storeAccount.findUnique({
|
||||||
@@ -116,27 +253,128 @@ export class AuthService {
|
|||||||
throw new NotImplementedException('FEATURE_DISABLED');
|
throw new NotImplementedException('FEATURE_DISABLED');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async mergeUsers(guestId: bigint, primaryId: bigint): Promise<UserRow> {
|
||||||
|
if (guestId === primaryId) {
|
||||||
|
return this.assertActiveUser(primaryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const guest = await tx.user.findUnique({ where: { id: guestId } });
|
||||||
|
const primary = await tx.user.findUnique({ where: { id: primaryId } });
|
||||||
|
if (!guest || guest.mergedIntoUserId || guest.status !== 1) {
|
||||||
|
throw new BadRequestException('访客账号无效');
|
||||||
|
}
|
||||||
|
if (!primary || primary.mergedIntoUserId || primary.status !== 1) {
|
||||||
|
throw new BadRequestException('目标账号无效');
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.order.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
|
||||||
|
await tx.userAddress.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
|
||||||
|
await tx.benefitCoupon.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
|
||||||
|
await tx.benefitLedger.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
|
||||||
|
await tx.redeemRecord.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
|
||||||
|
await tx.eventLog.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
|
||||||
|
|
||||||
|
const primaryPref = await tx.userCityPreference.findUnique({ where: { userId: primaryId } });
|
||||||
|
const guestPref = await tx.userCityPreference.findUnique({ where: { userId: guestId } });
|
||||||
|
if (!primaryPref && guestPref) {
|
||||||
|
await tx.userCityPreference.update({
|
||||||
|
where: { userId: guestId },
|
||||||
|
data: { userId: primaryId },
|
||||||
|
});
|
||||||
|
} else if (guestPref) {
|
||||||
|
await tx.userCityPreference.delete({ where: { userId: guestId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const guestPromo = await tx.userPromoAttribution.findUnique({ where: { userId: guestId } });
|
||||||
|
if (guestPromo) {
|
||||||
|
const primaryPromo = await tx.userPromoAttribution.findUnique({ where: { userId: primaryId } });
|
||||||
|
if (primaryPromo) {
|
||||||
|
await tx.userPromoAttribution.delete({ where: { userId: guestId } });
|
||||||
|
} else {
|
||||||
|
await tx.userPromoAttribution.update({
|
||||||
|
where: { userId: guestId },
|
||||||
|
data: { userId: primaryId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryUpdate: Prisma.UserUpdateInput = {};
|
||||||
|
if (guest.deviceKey && !primary.deviceKey) {
|
||||||
|
primaryUpdate.deviceKey = guest.deviceKey;
|
||||||
|
}
|
||||||
|
if (Object.keys(primaryUpdate).length > 0) {
|
||||||
|
await tx.user.update({ where: { id: primaryId }, data: primaryUpdate });
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: guestId },
|
||||||
|
data: {
|
||||||
|
mergedIntoUserId: primaryId,
|
||||||
|
status: 0,
|
||||||
|
deviceKey: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.assertActiveUser(primaryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertActiveUser(userId: bigint): Promise<UserRow> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) throw new NotFoundException('用户不存在');
|
||||||
|
if (user.mergedIntoUserId) {
|
||||||
|
throw new UnauthorizedException('账号已合并,请重新进入');
|
||||||
|
}
|
||||||
|
if (user.status !== 1) {
|
||||||
|
throw new ForbiddenException('账号已停用');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSessionResponse(user: UserRow, clientApp: ClientApp, deviceKey: string | null) {
|
||||||
|
const phoneVerified = !!user.phoneVerifiedAt;
|
||||||
|
return this.issueToken('USER', user.id, clientApp, phoneVerified, this.formatUserProfile(user), undefined, undefined, deviceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatUserProfile(user: UserRow) {
|
||||||
|
return {
|
||||||
|
id: user.id.toString(),
|
||||||
|
userNo: user.userNo,
|
||||||
|
phone: user.phone ? user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : null,
|
||||||
|
phoneVerified: !!user.phoneVerifiedAt,
|
||||||
|
nickname: user.nickname,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
hasWechat: !!user.wxOpenId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private issueToken(
|
private issueToken(
|
||||||
actorType: string,
|
actorType: string,
|
||||||
actorId: bigint,
|
actorId: bigint,
|
||||||
clientApp: ClientApp,
|
clientApp: ClientApp,
|
||||||
|
phoneVerified: boolean,
|
||||||
user?: Record<string, unknown>,
|
user?: Record<string, unknown>,
|
||||||
store?: Record<string, unknown>,
|
store?: Record<string, unknown>,
|
||||||
partner?: Record<string, unknown>,
|
partner?: Record<string, unknown>,
|
||||||
|
deviceKey?: string | null,
|
||||||
) {
|
) {
|
||||||
const payload = {
|
const payload = {
|
||||||
sub: actorId.toString(),
|
sub: actorId.toString(),
|
||||||
actorType,
|
actorType,
|
||||||
actorId: actorId.toString(),
|
actorId: actorId.toString(),
|
||||||
clientApp,
|
clientApp,
|
||||||
|
phoneVerified,
|
||||||
};
|
};
|
||||||
const accessToken = this.jwtService.sign(payload);
|
const accessToken = this.jwtService.sign(payload);
|
||||||
const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d' });
|
const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d' });
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
deviceKey: deviceKey ?? undefined,
|
||||||
actorType,
|
actorType,
|
||||||
actorId: actorId.toString(),
|
actorId: actorId.toString(),
|
||||||
|
phoneVerified,
|
||||||
user,
|
user,
|
||||||
store,
|
store,
|
||||||
partner,
|
partner,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class SendSmsDto {
|
export class SendSmsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -19,3 +19,25 @@ export class LoginSmsDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BootstrapSessionDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
deviceKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RefreshTokenDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BindPhoneDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
import { UserAddressController } from './user-address.controller';
|
import { UserAddressController } from './user-address.controller';
|
||||||
import { UserAddressService } from './user-address.service';
|
import { UserAddressService } from './user-address.service';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
|
||||||
|
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -27,7 +29,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|||||||
UserProfileController,
|
UserProfileController,
|
||||||
UserAddressController,
|
UserAddressController,
|
||||||
],
|
],
|
||||||
providers: [AuthService, UserAddressService, JwtAuthGuard],
|
providers: [AuthService, UserAddressService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard],
|
||||||
exports: [AuthService, JwtModule, JwtAuthGuard],
|
exports: [AuthService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard],
|
||||||
})
|
})
|
||||||
export class IamModule {}
|
export class IamModule {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Put, Query, Req, UseGuards } from '
|
|||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { TradeService } from './trade.service';
|
import { TradeService } from './trade.service';
|
||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
@Controller('trade/orders')
|
@Controller('trade/orders')
|
||||||
@@ -15,6 +16,7 @@ export class TradeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@UseGuards(PhoneVerifiedGuard)
|
||||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>, @Req() req: Request) {
|
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>, @Req() req: Request) {
|
||||||
return this.tradeService.createOrder(user.actorId, body as never, req);
|
return this.tradeService.createOrder(user.actorId, body as never, req);
|
||||||
}
|
}
|
||||||
@@ -35,6 +37,7 @@ export class TradeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/pay')
|
@Post(':id/pay')
|
||||||
|
@UseGuards(PhoneVerifiedGuard)
|
||||||
pay(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
pay(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
return this.tradeService.payOrder(user.actorId, BigInt(id));
|
return this.tradeService.payOrder(user.actorId, BigInt(id));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user