From 9f4577d3d82bc365d20cb419f01b5a8e03004b4a Mon Sep 17 00:00:00 2001
From: Jacy <18049821889@163.com>
Date: Wed, 1 Jul 2026 02:48:59 +0800
Subject: [PATCH] =?UTF-8?q?=E7=94=A8=E6=88=B7=E9=9D=99=E9=BB=98=E6=B3=A8?=
=?UTF-8?q?=E5=86=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/h5-user/src/App.tsx | 3 +
.../src/components/PhoneVerifySheet.tsx | 123 ++++++++
.../src/contexts/UserSessionContext.tsx | 114 +++++++
apps/h5-user/src/layouts/TabLayout.tsx | 8 +-
apps/h5-user/src/lib/api.ts | 156 ++++++++--
apps/h5-user/src/pages/LoginPage.tsx | 10 +-
apps/h5-user/src/pages/MinePage.tsx | 11 +-
apps/h5-user/src/pages/OrderConfirmPage.tsx | 73 +++--
apps/h5-user/src/styles.css | 56 ++++
server/dukang-api/prisma/schema.prisma | 26 +-
.../src/common/guards/jwt-auth.guard.ts | 2 +
.../common/guards/optional-jwt-auth.guard.ts | 38 +++
.../src/common/guards/phone-verified.guard.ts | 33 ++
.../src/modules/iam/auth.controller.ts | 37 ++-
.../src/modules/iam/auth.service.ts | 282 ++++++++++++++++--
.../src/modules/iam/dto/auth.dto.ts | 24 +-
.../dukang-api/src/modules/iam/iam.module.ts | 6 +-
.../src/modules/trade/trade.controller.ts | 3 +
18 files changed, 912 insertions(+), 93 deletions(-)
create mode 100644 apps/h5-user/src/components/PhoneVerifySheet.tsx
create mode 100644 apps/h5-user/src/contexts/UserSessionContext.tsx
create mode 100644 server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts
create mode 100644 server/dukang-api/src/common/guards/phone-verified.guard.ts
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 (
+
+
+
+
验证手机号
+
下单前需验证手机号,以便接收订单通知
+
+ +86
+ {
+ setPhone(normalizePhoneInput(e.target.value));
+ setMsg('');
+ }}
+ />
+
+
+ setCode(e.target.value)}
+ />
+
+
+ {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));
}