diff --git a/apps/h5-user/src/App.tsx b/apps/h5-user/src/App.tsx index 59004c1..65af6ba 100644 --- a/apps/h5-user/src/App.tsx +++ b/apps/h5-user/src/App.tsx @@ -17,9 +17,11 @@ import RedeemPage from './pages/RedeemPage'; import RedeemCodePage from './pages/RedeemCodePage'; import RedeemSuccessPage from './pages/RedeemSuccessPage'; import PayPage from './pages/PayPage'; +import { UserSessionProvider } from './contexts/UserSessionContext'; export default function App() { return ( + } /> }> @@ -43,5 +45,6 @@ export default function App() { } /> } /> + ); } diff --git a/apps/h5-user/src/components/PhoneVerifySheet.tsx b/apps/h5-user/src/components/PhoneVerifySheet.tsx new file mode 100644 index 0000000..9bfc2c5 --- /dev/null +++ b/apps/h5-user/src/components/PhoneVerifySheet.tsx @@ -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 ( +
+ +
+ {msg &&

{msg}

} + + + + ); +} diff --git a/apps/h5-user/src/contexts/UserSessionContext.tsx b/apps/h5-user/src/contexts/UserSessionContext.tsx new file mode 100644 index 0000000..ca5a156 --- /dev/null +++ b/apps/h5-user/src/contexts/UserSessionContext.tsx @@ -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; + resetSession: () => Promise; +}; + +const UserSessionContext = createContext(null); + +export function UserSessionProvider({ children }: { children: ReactNode }) { + const [ready, setReady] = useState(false); + const [profile, setProfile] = useState(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('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 ( +
+

加载中...

+
+ ); + } + + return {children}; +} + +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 }; diff --git a/apps/h5-user/src/layouts/TabLayout.tsx b/apps/h5-user/src/layouts/TabLayout.tsx index d51c438..86a08b4 100644 --- a/apps/h5-user/src/layouts/TabLayout.tsx +++ b/apps/h5-user/src/layouts/TabLayout.tsx @@ -1,5 +1,4 @@ -import { NavLink, Outlet, useNavigate } from 'react-router-dom'; -import { isLoggedIn } from '../lib/api'; +import { NavLink, Outlet } from 'react-router-dom'; const TABS = [ { to: '/', end: true, icon: 'home', label: '首页', fillActive: false }, @@ -9,11 +8,6 @@ const TABS = [ ] as const; export default function TabLayout() { - const navigate = useNavigate(); - if (!isLoggedIn()) { - navigate('/login'); - return null; - } return ( <> diff --git a/apps/h5-user/src/lib/api.ts b/apps/h5-user/src/lib/api.ts index ca66a08..9c9ab86 100644 --- a/apps/h5-user/src/lib/api.ts +++ b/apps/h5-user/src/lib/api.ts @@ -7,36 +7,148 @@ export const BRAND = { }; export const apiBase = '/api/v1'; +const CLIENT_APP = 'USER_H5'; -export async function request( - clientApp: string, - path: string, - options: RequestInit = {}, -): Promise { - const token = localStorage.getItem('accessToken'); - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Client-App': clientApp, - ...(options.headers as Record), - }; - if (token) headers.Authorization = `Bearer ${token}`; +export type UserProfile = { + id: string; + userNo: string; + phone: string | null; + phoneVerified: boolean; + nickname: string | null; + avatarUrl: string | null; + hasWechat: boolean; +}; - const res = await fetch(`${apiBase}${path}`, { ...options, headers }); - const json = await res.json(); - if (json.code !== 0) throw new Error(json.message || '请求失败'); - return json.data as T; +export type SessionPayload = { + accessToken: string; + refreshToken: string; + 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 }) { - localStorage.setItem('accessToken', data.accessToken); - if (data.refreshToken) localStorage.setItem('refreshToken', data.refreshToken); +export function saveSession(data: SessionPayload) { + localStorage.setItem(ACCESS_TOKEN, data.accessToken); + 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() { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); + localStorage.removeItem(ACCESS_TOKEN); + localStorage.removeItem(REFRESH_TOKEN); } export function isLoggedIn() { - return !!localStorage.getItem('accessToken'); + return !!localStorage.getItem(ACCESS_TOKEN); +} + +async function rawRequest( + path: string, + options: RequestInit = {}, + token?: string | null, +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Client-App': CLIENT_APP, + ...(options.headers as Record), + }; + 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( + _clientApp: string, + path: string, + options: RequestInit = {}, +): Promise { + return rawRequest(path, options); +} + +export async function bootstrapSession(): Promise { + const deviceKey = getDeviceKey(); + const data = await rawRequest( + '/auth/session/bootstrap', + { + method: 'POST', + body: JSON.stringify(deviceKey ? { deviceKey } : {}), + }, + null, + ); + saveSession(data); + return data; +} + +export async function refreshSession(): Promise { + const refreshToken = localStorage.getItem(REFRESH_TOKEN); + if (!refreshToken) return null; + try { + const data = await rawRequest( + '/auth/token/refresh', + { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }, + null, + ); + saveSession(data); + return data; + } catch { + return null; + } +} + +export async function ensureSession(): Promise { + if (isLoggedIn()) { + try { + const me = await rawRequest('/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 { + const data = await rawRequest('/auth/phone/bind', { + method: 'POST', + body: JSON.stringify({ phone, code }), + }); + saveSession(data); + return data; } diff --git a/apps/h5-user/src/pages/LoginPage.tsx b/apps/h5-user/src/pages/LoginPage.tsx index d7773b7..44d7e63 100644 --- a/apps/h5-user/src/pages/LoginPage.tsx +++ b/apps/h5-user/src/pages/LoginPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; 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'; export default function LoginPage() { @@ -60,11 +60,15 @@ export default function LoginPage() { setLoading(true); setMsg(''); 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', body: JSON.stringify({ phone, code }), }); - saveAuth(data); + saveSession(data); navigate('/'); } catch (e) { setMsg(e instanceof Error ? e.message : '登录失败'); diff --git a/apps/h5-user/src/pages/MinePage.tsx b/apps/h5-user/src/pages/MinePage.tsx index d4e2857..acf4db6 100644 --- a/apps/h5-user/src/pages/MinePage.tsx +++ b/apps/h5-user/src/pages/MinePage.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import TabMainHeader from '../components/TabMainHeader'; 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 = '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() { const navigate = useNavigate(); - const [profile, setProfile] = useState | null>(null); + const { profile: sessionProfile, resetSession } = useUserSession(); + const [profile, setProfile] = useState | null>( + sessionProfile as Record | null, + ); const [benefitBalance, setBenefitBalance] = useState(0); const [orderCounts, setOrderCounts] = useState>({}); const [toast, setToast] = useState(''); @@ -68,8 +72,7 @@ export default function MinePage() { } function logout() { - clearAuth(); - navigate('/login'); + void resetSession().then(() => navigate('/')); } const nickname = String(profile?.nickname || '用户'); diff --git a/apps/h5-user/src/pages/OrderConfirmPage.tsx b/apps/h5-user/src/pages/OrderConfirmPage.tsx index b4a9908..d0abbe2 100644 --- a/apps/h5-user/src/pages/OrderConfirmPage.tsx +++ b/apps/h5-user/src/pages/OrderConfirmPage.tsx @@ -6,6 +6,9 @@ import { request } from '../lib/api'; import { buildProductDetailUrl } from '../lib/navigation'; import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images'; import { tryGetClientGpsLocation } from '../lib/client-location'; +import { getProductMainImage } from '../lib/product-images'; +import PhoneVerifySheet from '../components/PhoneVerifySheet'; +import { useUserSession } from '../contexts/UserSessionContext'; type Address = { id: string; @@ -48,6 +51,9 @@ function formatAddress(a: Address) { export default function OrderConfirmPage() { const [params] = useSearchParams(); const navigate = useNavigate(); + const { phoneVerified, refreshProfile } = useUserSession(); + const [showPhoneVerify, setShowPhoneVerify] = useState(false); + const [pendingSubmit, setPendingSubmit] = useState(false); const productId = params.get('productId') || ''; const forceCross = params.get('cross') === '1'; const [quantity, setQuantity] = useState(Number(params.get('qty') || 2)); @@ -111,31 +117,55 @@ export default function OrderConfirmPage() { const productImage = 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() { if (!addressId) { setMsg('请选择收货地址'); return; } + if (!phoneVerified) { + setPendingSubmit(true); + setShowPhoneVerify(true); + return; + } setLoading(true); setMsg(''); try { - 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()}`); + await doSubmit(); + } catch (e) { + setMsg(e instanceof Error ? e.message : '下单失败'); + } finally { + setLoading(false); + } + } + + async function handlePhoneVerified() { + await refreshProfile(); + if (!pendingSubmit) return; + setPendingSubmit(false); + setLoading(true); + setMsg(''); + try { + await doSubmit(); } catch (e) { setMsg(e instanceof Error ? e.message : '下单失败'); } finally { @@ -296,6 +326,15 @@ export default function OrderConfirmPage() { + + { + setShowPhoneVerify(false); + setPendingSubmit(false); + }} + onSuccess={handlePhoneVerified} + /> ); } diff --git a/apps/h5-user/src/styles.css b/apps/h5-user/src/styles.css index 37cc8e0..166bdba 100644 --- a/apps/h5-user/src/styles.css +++ b/apps/h5-user/src/styles.css @@ -5449,3 +5449,59 @@ 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; +} + diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index f5d0b40..36ea989 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -206,17 +206,22 @@ model WxAppConfig { // ─── C端用户(phone 唯一主键,微信字段辅助)────────── model User { - id BigInt @id @default(autoincrement()) @db.UnsignedBigInt - userNo String @unique @map("user_no") @db.VarChar(20) - phone String @unique @db.VarChar(20) - wxOpenId String? @map("wx_open_id") @db.VarChar(64) - wxUnionId String? @map("wx_union_id") @db.VarChar(64) - nickname String? @db.VarChar(64) - 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) + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userNo String @unique @map("user_no") @db.VarChar(20) + deviceKey String? @unique @map("device_key") @db.VarChar(36) + phone String? @unique @db.VarChar(20) + phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3) + mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt + wxOpenId String? @map("wx_open_id") @db.VarChar(64) + wxUnionId String? @map("wx_union_id") @db.VarChar(64) + nickname String? @db.VarChar(64) + 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[] cityPref UserCityPreference? orders Order[] @@ -228,6 +233,7 @@ model User { @@index([wxOpenId]) @@index([wxUnionId]) + @@index([mergedIntoUserId]) @@map("users") } diff --git a/server/dukang-api/src/common/guards/jwt-auth.guard.ts b/server/dukang-api/src/common/guards/jwt-auth.guard.ts index 0b99f36..f9d1e6f 100644 --- a/server/dukang-api/src/common/guards/jwt-auth.guard.ts +++ b/server/dukang-api/src/common/guards/jwt-auth.guard.ts @@ -12,6 +12,7 @@ export interface AuthUser { actorId: bigint; clientApp: ClientApp; sub: string; + phoneVerified: boolean; } @Injectable() @@ -39,6 +40,7 @@ export class JwtAuthGuard implements CanActivate { actorId: BigInt(payload.actorId), clientApp, sub: payload.sub, + phoneVerified: !!payload.phoneVerified, } satisfies AuthUser; return true; } catch (err) { diff --git a/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts b/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts new file mode 100644 index 0000000..ddb335a --- /dev/null +++ b/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts @@ -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; + } +} diff --git a/server/dukang-api/src/common/guards/phone-verified.guard.ts b/server/dukang-api/src/common/guards/phone-verified.guard.ts new file mode 100644 index 0000000..0c35835 --- /dev/null +++ b/server/dukang-api/src/common/guards/phone-verified.guard.ts @@ -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 { + 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; + } +} diff --git a/server/dukang-api/src/modules/iam/auth.controller.ts b/server/dukang-api/src/modules/iam/auth.controller.ts index 8d538c4..73128f2 100644 --- a/server/dukang-api/src/modules/iam/auth.controller.ts +++ b/server/dukang-api/src/modules/iam/auth.controller.ts @@ -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 { 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 { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { AuthUser } from '../../common/guards/jwt-auth.guard'; import { ClientApp } from '@dukang/shared-types'; @@ -10,14 +18,33 @@ import { ClientApp } from '@dukang/shared-types'; export class UserAuthController { 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') sendSms(@Body() dto: SendSmsDto) { return this.authService.sendSms(dto.phone, dto.scene); } @Post('auth/login/sms') - login(@Body() dto: LoginSmsDto) { - return this.authService.loginUser(dto.phone, dto.code, ClientApp.USER_H5); + @UseGuards(OptionalJwtAuthGuard) + 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') @@ -26,7 +53,7 @@ export class UserAuthController { } @Post('auth/wechat/bind-phone') - bindPhone() { + bindPhoneLegacy() { return this.authService.wechatDisabled(); } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 5a473fa..1c72aec 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -1,8 +1,12 @@ +import { randomUUID } from 'crypto'; import { BadRequestException, + ForbiddenException, Inject, Injectable, + NotFoundException, NotImplementedException, + UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; 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 { ISmsProvider } from '../../integrations/sms/sms.interface'; 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() export class AuthService { @@ -25,28 +46,144 @@ export class AuthService { return { sent: true }; } - async loginUser(phone: string, code: string, clientApp: ClientApp) { - await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN); - let user = await this.prisma.user.findUnique({ where: { phone } }); - if (!user) { - user = await this.prisma.user.create({ - data: { - phone, - userNo: generateUserNo(), - nickname: `用户${phone.slice(-4)}`, + async bootstrapSession(deviceKey: string | undefined, clientApp: ClientApp) { + let user: UserRow | null = null; + let resolvedDeviceKey = deviceKey?.trim() || null; + + if (resolvedDeviceKey) { + user = await this.prisma.user.findFirst({ + where: { + deviceKey: resolvedDeviceKey, + 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(), - userNo: user.userNo, - phone: user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'), - nickname: user.nickname, - hasWechat: !!user.wxOpenId, - }); + + return this.buildSessionResponse(user, clientApp, resolvedDeviceKey); + } + + async refreshAccessToken(refreshToken: string, clientApp: ClientApp) { + 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) { @@ -60,7 +197,7 @@ export class AuthService { where: { id: account.id }, 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(), storeId: account.storeId.toString(), name: account.name, @@ -80,7 +217,7 @@ export class AuthService { where: { id: account.id }, 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(), partnerId: account.partnerId.toString(), name: account.name, @@ -92,8 +229,8 @@ export class AuthService { async getMe(actorType: string, actorId: bigint) { if (actorType === 'USER') { - const user = await this.prisma.user.findUnique({ where: { id: actorId } }); - return serializeBigInt(user); + const user = await this.assertActiveUser(actorId); + return this.formatUserProfile(user); } if (actorType === 'STORE') { const account = await this.prisma.storeAccount.findUnique({ @@ -116,27 +253,128 @@ export class AuthService { throw new NotImplementedException('FEATURE_DISABLED'); } + private async mergeUsers(guestId: bigint, primaryId: bigint): Promise { + 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 { + 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( actorType: string, actorId: bigint, clientApp: ClientApp, + phoneVerified: boolean, user?: Record, store?: Record, partner?: Record, + deviceKey?: string | null, ) { const payload = { sub: actorId.toString(), actorType, actorId: actorId.toString(), clientApp, + phoneVerified, }; const accessToken = this.jwtService.sign(payload); const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d' }); return { accessToken, refreshToken, + deviceKey: deviceKey ?? undefined, actorType, actorId: actorId.toString(), + phoneVerified, user, store, partner, diff --git a/server/dukang-api/src/modules/iam/dto/auth.dto.ts b/server/dukang-api/src/modules/iam/dto/auth.dto.ts index c229ac7..bb17ab6 100644 --- a/server/dukang-api/src/modules/iam/dto/auth.dto.ts +++ b/server/dukang-api/src/modules/iam/dto/auth.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class SendSmsDto { @IsString() @@ -19,3 +19,25 @@ export class LoginSmsDto { @IsNotEmpty() 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; +} diff --git a/server/dukang-api/src/modules/iam/iam.module.ts b/server/dukang-api/src/modules/iam/iam.module.ts index dd1f3b6..9c65733 100644 --- a/server/dukang-api/src/modules/iam/iam.module.ts +++ b/server/dukang-api/src/modules/iam/iam.module.ts @@ -11,6 +11,8 @@ import { import { UserAddressController } from './user-address.controller'; import { UserAddressService } from './user-address.service'; 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({ imports: [ @@ -27,7 +29,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; UserProfileController, UserAddressController, ], - providers: [AuthService, UserAddressService, JwtAuthGuard], - exports: [AuthService, JwtModule, JwtAuthGuard], + providers: [AuthService, UserAddressService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard], + exports: [AuthService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard], }) export class IamModule {} diff --git a/server/dukang-api/src/modules/trade/trade.controller.ts b/server/dukang-api/src/modules/trade/trade.controller.ts index 6787147..4184268 100644 --- a/server/dukang-api/src/modules/trade/trade.controller.ts +++ b/server/dukang-api/src/modules/trade/trade.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Put, Query, Req, UseGuards } from ' import type { Request } from 'express'; import { TradeService } from './trade.service'; 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'; @Controller('trade/orders') @@ -15,6 +16,7 @@ export class TradeController { } @Post() + @UseGuards(PhoneVerifiedGuard) create(@CurrentUser() user: AuthUser, @Body() body: Record, @Req() req: Request) { return this.tradeService.createOrder(user.actorId, body as never, req); } @@ -35,6 +37,7 @@ export class TradeController { } @Post(':id/pay') + @UseGuards(PhoneVerifiedGuard) pay(@CurrentUser() user: AuthUser, @Param('id') id: string) { return this.tradeService.payOrder(user.actorId, BigInt(id)); }