门店账号管理修改
This commit is contained in:
@@ -8,6 +8,7 @@ export type StoreCreateForm = {
|
||||
districtCode?: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
smsCode: string;
|
||||
address: string;
|
||||
intro?: string;
|
||||
coverUrl?: string;
|
||||
@@ -16,22 +17,21 @@ export type StoreCreateForm = {
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
accountPhone?: string;
|
||||
accountName?: string;
|
||||
};
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
|
||||
export function validateStoreCreateStep1(
|
||||
form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
||||
form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'smsCode' | 'address' | 'intro'>,
|
||||
): string | null {
|
||||
if (!form.partnerId) return '请选择开城合伙人';
|
||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 开城城市」配置对应区划';
|
||||
if (!form.name?.trim()) return '请填写门店名称';
|
||||
if (!form.phone?.trim()) return '请填写联系电话';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||
if (!form.smsCode?.trim()) return '请填写短信验证码';
|
||||
if (!form.address?.trim()) return '请填写详细地址';
|
||||
if (form.intro?.trim()) {
|
||||
const len = form.intro.trim().length;
|
||||
|
||||
@@ -59,7 +59,10 @@ export default function StoreAccountsPage() {
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
||||
<Typography.Text type="secondary">新建门店时自动开通主账号;「新建账户」仅用于补录历史无账号门店</Typography.Text>
|
||||
</Space>
|
||||
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新建账户</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
|
||||
@@ -81,6 +81,7 @@ export default function StoresPage() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createStep, setCreateStep] = useState(0);
|
||||
const [createError, setCreateError] = useState('');
|
||||
const [smsCooldown, setSmsCooldown] = useState(0);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||
@@ -138,9 +139,38 @@ export default function StoresPage() {
|
||||
setCreateOpen(false);
|
||||
setCreateStep(0);
|
||||
setCreateError('');
|
||||
setSmsCooldown(0);
|
||||
createForm.resetFields();
|
||||
}
|
||||
|
||||
async function sendCreateSms() {
|
||||
const phone = String(createForm.getFieldValue('phone') ?? '').trim();
|
||||
if (!/^1\d{10}$/.test(phone)) {
|
||||
message.error('请先填写正确的11位门店手机号');
|
||||
return;
|
||||
}
|
||||
if (smsCooldown > 0) return;
|
||||
try {
|
||||
await request('/admin/stores/phone/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
message.success('验证码已发送');
|
||||
setSmsCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setSmsCooldown((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '发送失败');
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
void loadOptions();
|
||||
createForm.setFieldsValue({
|
||||
@@ -160,7 +190,7 @@ export default function StoresPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
||||
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'smsCode', 'address']);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -188,6 +218,7 @@ export default function StoresPage() {
|
||||
city: values.city,
|
||||
name: values.name.trim(),
|
||||
phone: values.phone.trim(),
|
||||
smsCode: values.smsCode.trim(),
|
||||
district: values.district.trim(),
|
||||
address: values.address.trim(),
|
||||
intro: values.intro?.trim() || undefined,
|
||||
@@ -197,8 +228,6 @@ export default function StoresPage() {
|
||||
bankAccountName: values.bankAccountName.trim(),
|
||||
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
||||
bankBranch: values.bankBranch.trim(),
|
||||
accountPhone: values.accountPhone?.trim() || undefined,
|
||||
accountName: values.accountName?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('门店已创建');
|
||||
@@ -208,7 +237,7 @@ export default function StoresPage() {
|
||||
if (e && typeof e === 'object' && 'errorFields' in e) {
|
||||
const fields = e as { errorFields?: Array<{ name: string[] }> };
|
||||
const first = fields.errorFields?.[0]?.name?.[0];
|
||||
if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
|
||||
if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone' || first === 'smsCode') {
|
||||
setCreateStep(0);
|
||||
}
|
||||
return;
|
||||
@@ -370,21 +399,25 @@ export default function StoresPage() {
|
||||
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
||||
<Input placeholder="请输入门店名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="联系电话" rules={[{ required: true, message: '请填写联系电话' }]}>
|
||||
<Form.Item name="phone" label="门店手机号(登录账号)" rules={[{ required: true, message: '请填写门店手机号' }]}>
|
||||
<Input placeholder="11位手机号" />
|
||||
</Form.Item>
|
||||
<Form.Item label="短信验证" required>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name="smsCode" noStyle rules={[{ required: true, message: '请填写验证码' }]}>
|
||||
<Input placeholder="验证码" maxLength={6} />
|
||||
</Form.Item>
|
||||
<Button disabled={smsCooldown > 0} onClick={() => void sendCreateSms()}>
|
||||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
||||
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="intro" label="门店简介">
|
||||
<Input.TextArea rows={3} placeholder="选填,10~500字" showCount maxLength={500} />
|
||||
</Form.Item>
|
||||
<Form.Item name="accountPhone" label="店长手机">
|
||||
<Input placeholder="默认同门店电话" />
|
||||
</Form.Item>
|
||||
<Form.Item name="accountName" label="店长姓名">
|
||||
<Input placeholder="默认同门店名" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: createStep === 1 ? 'block' : 'none' }}>
|
||||
<Typography.Paragraph type="secondary">
|
||||
|
||||
@@ -8,8 +8,6 @@ export type StoreDraftForm = {
|
||||
phone: string;
|
||||
address: string;
|
||||
intro: string;
|
||||
accountPhone: string;
|
||||
accountName: string;
|
||||
coverUrl: string;
|
||||
envPhotoUrls: string[];
|
||||
contractUrl: string;
|
||||
@@ -35,8 +33,6 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
phone: '',
|
||||
address: '',
|
||||
intro: '',
|
||||
accountPhone: '',
|
||||
accountName: '',
|
||||
coverUrl: '',
|
||||
envPhotoUrls: ['', '', ''],
|
||||
contractUrl: '',
|
||||
@@ -67,8 +63,6 @@ function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
|
||||
phone: String(raw.phone ?? base.phone),
|
||||
address: String(raw.address ?? base.address),
|
||||
intro: String(raw.intro ?? base.intro),
|
||||
accountPhone: String(raw.accountPhone ?? base.accountPhone),
|
||||
accountName: String(raw.accountName ?? base.accountName),
|
||||
coverUrl: String(raw.coverUrl ?? base.coverUrl),
|
||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
||||
contractUrl: String(raw.contractUrl ?? base.contractUrl),
|
||||
|
||||
@@ -148,8 +148,6 @@ export default function StoreCreatePage() {
|
||||
bankAccountName: form.bankAccountName.trim(),
|
||||
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
|
||||
bankBranch: form.bankBranch.trim(),
|
||||
accountPhone: form.accountPhone.trim() || undefined,
|
||||
accountName: form.accountName.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
clearStoreDraft();
|
||||
@@ -216,10 +214,10 @@ export default function StoreCreatePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>联系电话 <span className="text-primary">*</span></label>
|
||||
<label>联系电话(门店登录账号) <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<input type="tel" placeholder="请输入联系电话" value={form.phone} onChange={(e) => patchForm({ phone: e.target.value })} />
|
||||
<input type="tel" placeholder="请输入11位手机号" value={form.phone} onChange={(e) => patchForm({ phone: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
@@ -233,20 +231,6 @@ export default function StoreCreatePage() {
|
||||
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>店长手机</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">smartphone</span>
|
||||
<input type="tel" placeholder="默认同门店电话" value={form.accountPhone} onChange={(e) => patchForm({ accountPhone: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>店长姓名</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
<input placeholder="默认同门店名" value={form.accountName} onChange={(e) => patchForm({ accountName: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="partner-info-banner">
|
||||
<div className="partner-bills-icon">
|
||||
|
||||
+15
-12
@@ -1,4 +1,5 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import AuthGate from './components/AuthGate';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
@@ -10,17 +11,19 @@ import MinePage from './pages/MinePage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/records" element={<RecordsPage />} />
|
||||
<Route path="/status" element={<StatusPage />} />
|
||||
<Route path="/mine" element={<MinePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<AuthGate>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/records" element={<RecordsPage />} />
|
||||
<Route path="/status" element={<StatusPage />} />
|
||||
<Route path="/mine" element={<MinePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login']);
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const { ready, authenticated } = useStoreSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="session-boot">
|
||||
<p className="session-boot-text">加载中…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (authenticated && location.pathname === '/login') {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
clearAuth,
|
||||
ensureSession,
|
||||
saveAuth,
|
||||
type ShopSessionPayload,
|
||||
type StoreSessionStore,
|
||||
} from '../lib/api';
|
||||
|
||||
type StoreSessionContextValue = {
|
||||
ready: boolean;
|
||||
authenticated: boolean;
|
||||
store: StoreSessionStore | null;
|
||||
applySession: (session: ShopSessionPayload) => void;
|
||||
resetSession: () => void;
|
||||
};
|
||||
|
||||
const StoreSessionContext = createContext<StoreSessionContextValue | null>(null);
|
||||
|
||||
export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [store, setStore] = useState<StoreSessionStore | null>(null);
|
||||
|
||||
const applySession = useCallback((session: ShopSessionPayload) => {
|
||||
saveAuth(session);
|
||||
setAuthenticated(true);
|
||||
if (session.store) setStore(session.store);
|
||||
}, []);
|
||||
|
||||
const resetSession = useCallback(() => {
|
||||
clearAuth();
|
||||
setAuthenticated(false);
|
||||
setStore(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await ensureSession();
|
||||
if (cancelled) return;
|
||||
setAuthenticated(result.authenticated);
|
||||
setStore(result.store);
|
||||
} catch {
|
||||
if (!cancelled) resetSession();
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resetSession]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ ready, authenticated, store, applySession, resetSession }),
|
||||
[ready, authenticated, store, applySession, resetSession],
|
||||
);
|
||||
|
||||
return <StoreSessionContext.Provider value={value}>{children}</StoreSessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useStoreSession() {
|
||||
const ctx = useContext(StoreSessionContext);
|
||||
if (!ctx) throw new Error('useStoreSession must be used within StoreSessionProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -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: '首页' },
|
||||
@@ -8,11 +7,6 @@ const TABS = [
|
||||
] as const;
|
||||
|
||||
export default function TabLayout() {
|
||||
const navigate = useNavigate();
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
|
||||
+162
-16
@@ -1,27 +1,173 @@
|
||||
export const apiBase = '/api/v1';
|
||||
const CLIENT_APP = 'SHOP_H5';
|
||||
|
||||
export async function request<T>(clientApp: string, path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
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 StoreSessionStore = {
|
||||
id: string;
|
||||
storeId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
storeName: string;
|
||||
};
|
||||
|
||||
export type StoreProfile = {
|
||||
id: string;
|
||||
storeId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
status?: string;
|
||||
store?: { id: string; name: string };
|
||||
};
|
||||
|
||||
export type ShopSessionPayload = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
store?: StoreSessionStore;
|
||||
};
|
||||
|
||||
const ACCESS_TOKEN = 'accessToken';
|
||||
const REFRESH_TOKEN = 'refreshToken';
|
||||
const LAST_PHONE = 'shopLastPhone';
|
||||
const STORE_PROFILE = 'shopStoreProfile';
|
||||
|
||||
const AUTH_RECOVERY_EXEMPT_PATHS = ['/shop/auth/token/refresh', '/shop/auth/sms/send', '/shop/auth/login/sms'];
|
||||
|
||||
export function getLastPhone() {
|
||||
return localStorage.getItem(LAST_PHONE) ?? '';
|
||||
}
|
||||
|
||||
export function saveAuth(data: { accessToken: string }) {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
export function getStoreProfile(): StoreSessionStore | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORE_PROFILE);
|
||||
return raw ? (JSON.parse(raw) as StoreSessionStore) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAuth(data: ShopSessionPayload) {
|
||||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||
if (data.store) {
|
||||
localStorage.setItem(STORE_PROFILE, JSON.stringify(data.store));
|
||||
localStorage.setItem(LAST_PHONE, data.store.phone);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem(ACCESS_TOKEN);
|
||||
localStorage.removeItem(REFRESH_TOKEN);
|
||||
localStorage.removeItem(STORE_PROFILE);
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem('accessToken');
|
||||
return !!localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
function profileFromMe(me: StoreProfile): StoreSessionStore {
|
||||
return {
|
||||
id: me.id,
|
||||
storeId: me.storeId,
|
||||
name: me.name,
|
||||
phone: me.phone,
|
||||
storeName: me.store?.name ?? me.name,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function refreshSession(): Promise<ShopSessionPayload | null> {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const data = await rawRequest<ShopSessionPayload>(
|
||||
'/shop/auth/token/refresh',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
},
|
||||
null,
|
||||
);
|
||||
saveAuth(data);
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestWithAuthRetry<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
retried = false,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await rawRequest<T>(path, options);
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
const canRecover =
|
||||
err.status === 401 &&
|
||||
!retried &&
|
||||
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
|
||||
if (!canRecover) throw e;
|
||||
const refreshed = await refreshSession();
|
||||
if (!refreshed) {
|
||||
clearAuth();
|
||||
throw e;
|
||||
}
|
||||
return requestWithAuthRetry<T>(path, options, true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(clientApp: string, path: string, options: RequestInit = {}): Promise<T> {
|
||||
void clientApp;
|
||||
return requestWithAuthRetry<T>(path, options);
|
||||
}
|
||||
|
||||
export async function ensureSession(): Promise<{ authenticated: boolean; store: StoreSessionStore | null }> {
|
||||
if (!isLoggedIn()) {
|
||||
return { authenticated: false, store: null };
|
||||
}
|
||||
try {
|
||||
const me = await rawRequest<StoreProfile>('/shop/auth/me');
|
||||
const store = profileFromMe(me);
|
||||
saveAuth({
|
||||
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||
store,
|
||||
});
|
||||
return { authenticated: true, store };
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
if (err.status === 401) {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed?.store) {
|
||||
return { authenticated: true, store: refreshed.store };
|
||||
}
|
||||
clearAuth();
|
||||
return { authenticated: false, store: null };
|
||||
}
|
||||
const cached = getStoreProfile();
|
||||
if (cached) return { authenticated: true, store: cached };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { StoreSessionProvider } from './contexts/StoreSessionContext';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode><BrowserRouter><App /></BrowserRouter></React.StrictMode>,
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<StoreSessionProvider>
|
||||
<App />
|
||||
</StoreSessionProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { request } from '../lib/api';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
|
||||
function formatMoney(n: number) {
|
||||
@@ -13,15 +13,11 @@ export default function HomePage() {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
request('SHOP_H5', '/shop/dashboard').then((d) => {
|
||||
setDash(d);
|
||||
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
|
||||
});
|
||||
}, [navigate]);
|
||||
}, []);
|
||||
|
||||
const store = dash?.store as Record<string, unknown> | undefined;
|
||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request, saveAuth } from '../lib/api';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getLastPhone, getStoreProfile, request, type ShopSessionPayload } from '../lib/api';
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
if (phone.length < 7) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
const DEV_DEFAULT_PHONE = import.meta.env.DEV ? '13900000001' : '';
|
||||
const DEV_DEFAULT_CODE = import.meta.env.DEV ? '123456' : '';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession } = useStoreSession();
|
||||
const [params] = useSearchParams();
|
||||
const quick = params.get('quick') === '1';
|
||||
const [phone, setPhone] = useState('13900000001');
|
||||
const [code, setCode] = useState('123456');
|
||||
const savedProfile = getStoreProfile();
|
||||
const [phone, setPhone] = useState(getLastPhone() || DEV_DEFAULT_PHONE);
|
||||
const [code, setCode] = useState(DEV_DEFAULT_CODE);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
|
||||
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
|
||||
const quickPhone = savedProfile?.phone || phone;
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先阅读并同意用户协议');
|
||||
@@ -35,7 +44,7 @@ export default function LoginPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
|
||||
});
|
||||
setMsg('验证码已发送(Mock: 123456)');
|
||||
setMsg(import.meta.env.DEV ? '验证码已发送(Mock: 123456)' : '验证码已发送');
|
||||
setCodeCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCodeCooldown((c) => {
|
||||
@@ -51,20 +60,22 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (!ensureAgreed()) return;
|
||||
async function login(options?: { quick?: boolean }) {
|
||||
if (!options?.quick && !ensureAgreed()) return;
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/auth/sms/send', {
|
||||
if (options?.quick) {
|
||||
await request('SHOP_H5', '/shop/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: quickPhone, scene: 'STORE_LOGIN' }),
|
||||
});
|
||||
}
|
||||
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
|
||||
body: JSON.stringify({ phone: options?.quick ? quickPhone : phone, code }),
|
||||
});
|
||||
const data = await request<{ accessToken: string }>('SHOP_H5', '/shop/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveAuth(data);
|
||||
applySession(data);
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
@@ -93,8 +104,8 @@ export default function LoginPage() {
|
||||
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="shop-quick-store-name">门店管理中心</h2>
|
||||
<p className="shop-quick-store-phone">{maskPhone(phone)}</p>
|
||||
<h2 className="shop-quick-store-name">{quickStoreName}</h2>
|
||||
<p className="shop-quick-store-phone">{maskPhone(quickPhone)}</p>
|
||||
</div>
|
||||
<span className="shop-quick-verified">
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
|
||||
@@ -115,7 +126,7 @@ export default function LoginPage() {
|
||||
type="button"
|
||||
className="shop-quick-login-btn"
|
||||
disabled={loading}
|
||||
onClick={login}
|
||||
onClick={() => void login({ quick: true })}
|
||||
>
|
||||
<span>{loading ? '登录中...' : '一键登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { clearAuth, isLoggedIn, request } from '../lib/api';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
export default function MinePage() {
|
||||
const navigate = useNavigate();
|
||||
const { resetSession } = useStoreSession();
|
||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
request('SHOP_H5', '/shop/store').then(setStore);
|
||||
}, [navigate]);
|
||||
}, []);
|
||||
|
||||
const openTime = String(store?.openTime || '09:30');
|
||||
const closeTime = String(store?.closeTime || '22:00');
|
||||
@@ -65,7 +63,7 @@ export default function MinePage() {
|
||||
<button
|
||||
type="button"
|
||||
className="shop-mine-logout"
|
||||
onClick={() => { clearAuth(); navigate('/login'); }}
|
||||
onClick={() => { resetSession(); navigate('/login'); }}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
|
||||
退出登录
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { clearAuth, request } from '../lib/api';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
export default function StatusPage() {
|
||||
const navigate = useNavigate();
|
||||
const { resetSession } = useStoreSession();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState('');
|
||||
@@ -58,7 +60,7 @@ export default function StatusPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="shop-status-logout app-page-header-action app-page-header-action--end"
|
||||
onClick={() => { clearAuth(); navigate('/login'); }}
|
||||
onClick={() => { resetSession(); navigate('/login'); }}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
|
||||
退出登录
|
||||
|
||||
@@ -1918,3 +1918,16 @@
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.session-boot {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.session-boot-text {
|
||||
color: var(--color-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export enum ActorType {
|
||||
export enum SmsScene {
|
||||
USER_LOGIN = 'USER_LOGIN',
|
||||
STORE_LOGIN = 'STORE_LOGIN',
|
||||
STORE_ACCOUNT_OPEN = 'STORE_ACCOUNT_OPEN',
|
||||
PARTNER_LOGIN = 'PARTNER_LOGIN',
|
||||
HQ_LOGIN = 'HQ_LOGIN',
|
||||
BIND_PHONE = 'BIND_PHONE',
|
||||
|
||||
@@ -121,6 +121,15 @@ async function main() {
|
||||
body: JSON.stringify({ couponId: coupons[0].id, amount: Number(coupons[0].balance) + 1 }),
|
||||
});
|
||||
|
||||
console.log('7b. Shop sms guard + session');
|
||||
const unboundMsg = await expectFail('SHOP_H5', '/shop/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13899999999', scene: 'STORE_LOGIN' }),
|
||||
});
|
||||
if (!unboundMsg.includes('未绑定门店')) {
|
||||
throw new Error(`Expected unbound store message, got: ${unboundMsg}`);
|
||||
}
|
||||
|
||||
console.log('8. Shop confirm redeem');
|
||||
const shopLogin = await loginSms(
|
||||
'SHOP_H5',
|
||||
@@ -129,6 +138,13 @@ async function main() {
|
||||
'/shop/auth/login/sms',
|
||||
'/shop/auth/sms/send',
|
||||
);
|
||||
const shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopLogin.accessToken });
|
||||
if (!shopMe?.phone) throw new Error('Shop /shop/auth/me failed');
|
||||
const refreshed = await req('SHOP_H5', '/shop/auth/token/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken: shopLogin.refreshToken }),
|
||||
});
|
||||
if (!refreshed?.accessToken) throw new Error('Shop token refresh failed');
|
||||
const preview = await req('SHOP_H5', '/shop/redeem/preview', {
|
||||
method: 'POST',
|
||||
token: shopLogin.accessToken,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 门店登录 / 建店短信校验专项冒烟(需 API 已启动且 MOCK_SMS 开启)
|
||||
* 用法: node scripts/test-shop-auth.mjs
|
||||
*/
|
||||
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
|
||||
const MOCK_SMS_CODE = process.env.MOCK_SMS_CODE ?? '123456';
|
||||
|
||||
async function req(clientApp, path, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
};
|
||||
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(`${path}: ${json.message}`);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function expectFail(clientApp, path, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
};
|
||||
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
|
||||
const json = await res.json();
|
||||
if (json.code === 0) throw new Error(`${path}: expected failure`);
|
||||
return json.message;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('1. STORE_LOGIN unbound phone');
|
||||
const unbound = await expectFail('SHOP_H5', '/shop/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13899999999', scene: 'STORE_LOGIN' }),
|
||||
});
|
||||
if (!unbound.includes('未绑定门店')) throw new Error(`unexpected: ${unbound}`);
|
||||
|
||||
console.log('2. STORE_LOGIN bound phone');
|
||||
await req('SHOP_H5', '/shop/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13900000001', scene: 'STORE_LOGIN' }),
|
||||
});
|
||||
const login = await req('SHOP_H5', '/shop/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13900000001', code: MOCK_SMS_CODE }),
|
||||
});
|
||||
if (!login.accessToken || !login.refreshToken) throw new Error('login missing tokens');
|
||||
|
||||
console.log('3. Shop session me + refresh');
|
||||
const me = await req('SHOP_H5', '/shop/auth/me', { token: login.accessToken });
|
||||
if (me.phone !== '13900000001') throw new Error('me phone mismatch');
|
||||
const refreshed = await req('SHOP_H5', '/shop/auth/token/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken: login.refreshToken }),
|
||||
});
|
||||
if (!refreshed.accessToken) throw new Error('refresh failed');
|
||||
|
||||
console.log('4. STORE_ACCOUNT_OPEN occupied phone');
|
||||
await req('HQ_WEB', '/admin/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13600000001', scene: 'HQ_LOGIN' }),
|
||||
});
|
||||
const admin = await req('HQ_WEB', '/admin/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13600000001', code: MOCK_SMS_CODE }),
|
||||
});
|
||||
const occupied = await expectFail('HQ_WEB', '/admin/stores/phone/sms/send', {
|
||||
method: 'POST',
|
||||
token: admin.accessToken,
|
||||
body: JSON.stringify({ phone: '13900000001' }),
|
||||
});
|
||||
if (!occupied.includes('已绑定')) throw new Error(`unexpected: ${occupied}`);
|
||||
|
||||
console.log('\n✅ shop-auth tests passed');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -115,6 +115,17 @@ export class ShopAuthController {
|
||||
wechatLogin(@Body() dto: LoginWechatDto) {
|
||||
return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
|
||||
}
|
||||
|
||||
@Post('token/refresh')
|
||||
refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refreshAccessToken(dto.refreshToken, ClientApp.SHOP_H5);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.getMe(user.actorType, user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/auth')
|
||||
|
||||
@@ -80,6 +80,8 @@ export class AuthService {
|
||||
switch (scene) {
|
||||
case SmsScene.STORE_LOGIN:
|
||||
return ClientApp.SHOP_H5;
|
||||
case SmsScene.STORE_ACCOUNT_OPEN:
|
||||
return ClientApp.HQ_WEB;
|
||||
case SmsScene.PARTNER_LOGIN:
|
||||
case SmsScene.PARTNER_STAFF_ADD:
|
||||
return ClientApp.PARTNER_H5;
|
||||
@@ -148,6 +150,24 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
private async assertSmsSendAllowed(phone: string, scene: SmsScene) {
|
||||
if (scene === SmsScene.STORE_LOGIN) {
|
||||
const account = await this.prisma.storeAccount.findUnique({ where: { phone } });
|
||||
if (!account) throw new BadRequestException('该手机号未绑定门店');
|
||||
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
|
||||
return;
|
||||
}
|
||||
if (scene === SmsScene.STORE_ACCOUNT_OPEN) {
|
||||
const existing = await this.prisma.storeAccount.findUnique({ where: { phone } });
|
||||
if (existing) throw new BadRequestException('该手机号已绑定门店');
|
||||
}
|
||||
}
|
||||
|
||||
async verifySmsCode(phone: string, code: string, scene: SmsScene) {
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
await this.smsProvider.verify(normalizedPhone, code, scene);
|
||||
}
|
||||
|
||||
private async verifySmsForUser(
|
||||
phone: string,
|
||||
code: string,
|
||||
@@ -178,6 +198,7 @@ export class AuthService {
|
||||
if (!Object.values(SmsScene).includes(scene as SmsScene)) {
|
||||
throw new BadRequestException('无效的验证码场景');
|
||||
}
|
||||
await this.assertSmsSendAllowed(normalizedPhone, scene as SmsScene);
|
||||
const clientApp = opts?.clientApp ?? this.clientAppForScene(scene);
|
||||
const actorRef = await this.resolveSmsActorRef(normalizedPhone, scene, opts?.guestUserId);
|
||||
const userId = actorRef?.refType === 'USER' ? actorRef.refId : opts?.guestUserId;
|
||||
@@ -254,17 +275,40 @@ export class AuthService {
|
||||
async refreshAccessToken(refreshToken: string, clientApp: ClientApp) {
|
||||
try {
|
||||
const payload = this.jwtService.verify(refreshToken);
|
||||
if (payload.clientApp !== clientApp || payload.actorType !== 'USER') {
|
||||
if (payload.clientApp !== clientApp) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
const user = await this.assertActiveUser(BigInt(payload.actorId));
|
||||
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||
if (payload.actorType === 'USER') {
|
||||
const user = await this.assertActiveUser(BigInt(payload.actorId));
|
||||
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||
}
|
||||
if (payload.actorType === 'STORE' && clientApp === ClientApp.SHOP_H5) {
|
||||
return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp);
|
||||
}
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) throw err;
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
private async buildStoreSessionResponse(accountId: bigint, clientApp: ClientApp) {
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id: accountId },
|
||||
include: { store: true },
|
||||
});
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
return this.issueToken('STORE', account.id, clientApp, false, undefined, {
|
||||
id: account.id.toString(),
|
||||
storeId: account.storeId.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
storeName: account.store.name,
|
||||
});
|
||||
}
|
||||
|
||||
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
@@ -396,7 +440,8 @@ export class AuthService {
|
||||
where: { phone: normalizedPhone },
|
||||
include: { store: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('门店账号不存在');
|
||||
if (!account) throw new BadRequestException('该手机号未绑定门店');
|
||||
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
|
||||
await this.prisma.storeAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
|
||||
@@ -28,6 +28,11 @@ export class AdminStoresController {
|
||||
return this.service.listStores(query);
|
||||
}
|
||||
|
||||
@Post('phone/sms/send')
|
||||
sendOpenSms(@Body() body: { phone: string }) {
|
||||
return this.service.sendStoreOpenSms(body.phone);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detailStore(BigInt(id));
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { ClientApp, SmsScene } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||
import { AuthService } from '../iam/auth.service';
|
||||
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||||
import type {
|
||||
CreateStoreAccountDto,
|
||||
@@ -16,7 +18,16 @@ import type {
|
||||
|
||||
@Injectable()
|
||||
export class AdminStoresService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
async sendStoreOpenSms(phone: string) {
|
||||
return this.authService.sendSms(phone, SmsScene.STORE_ACCOUNT_OPEN, {
|
||||
clientApp: ClientApp.HQ_WEB,
|
||||
});
|
||||
}
|
||||
|
||||
async listStores(query: AdminStoresQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
@@ -153,6 +164,16 @@ export class AdminStoresService {
|
||||
}
|
||||
|
||||
async createStore(dto: CreateStoreDto) {
|
||||
const normalizedPhone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
await this.authService.verifySmsCode(normalizedPhone, dto.smsCode, SmsScene.STORE_ACCOUNT_OPEN);
|
||||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
});
|
||||
if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
|
||||
|
||||
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } });
|
||||
if (!partner) throw new BadRequestException('开城合伙人不存在');
|
||||
const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } });
|
||||
@@ -167,7 +188,7 @@ export class AdminStoresService {
|
||||
partnerId: partner.id,
|
||||
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
|
||||
name: dto.name,
|
||||
phone: dto.phone,
|
||||
phone: normalizedPhone,
|
||||
province: dto.province ?? city.province,
|
||||
cityName: dto.city ?? city.name,
|
||||
district: dto.district ?? '',
|
||||
@@ -243,7 +264,7 @@ export class AdminStoresService {
|
||||
await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
phone: dto.accountPhone ?? dto.phone,
|
||||
phone: normalizedPhone,
|
||||
name: dto.accountName ?? dto.name,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +24,10 @@ export class CreateStoreDto {
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
smsCode: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
@@ -52,10 +56,6 @@ export class CreateStoreDto {
|
||||
@IsString()
|
||||
coverUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountName?: string;
|
||||
|
||||
@@ -83,6 +83,15 @@ export class StoreService {
|
||||
|
||||
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const normalizedPhone = String(body.phone).trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||
throw new BadRequestException('联系电话须为11位手机号');
|
||||
}
|
||||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
});
|
||||
if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
|
||||
|
||||
const city = await this.resolvePartnerCity(account.partnerId, body.cityId);
|
||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
|
||||
@@ -96,7 +105,7 @@ export class StoreService {
|
||||
partnerId: account.partnerId,
|
||||
categoryId: body.categoryId ? BigInt(String(body.categoryId)) : null,
|
||||
name: String(body.name),
|
||||
phone: String(body.phone),
|
||||
phone: normalizedPhone,
|
||||
province: String(body.province ?? city.province ?? '河南省'),
|
||||
cityName: String(body.city ?? city.name ?? '郑州市'),
|
||||
district: String(body.district ?? ''),
|
||||
@@ -174,11 +183,8 @@ export class StoreService {
|
||||
await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
phone: await this.resolveStoreAccountPhone(
|
||||
String(body.accountPhone ?? body.phone),
|
||||
store.id,
|
||||
),
|
||||
name: String(body.accountName ?? body.name),
|
||||
phone: normalizedPhone,
|
||||
name: String(body.name),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -310,13 +316,4 @@ export class StoreService {
|
||||
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
||||
return city;
|
||||
}
|
||||
|
||||
private async resolveStoreAccountPhone(phone: string, storeId: bigint): Promise<string> {
|
||||
const normalized = phone.trim();
|
||||
const existing = await this.prisma.storeAccount.findUnique({ where: { phone: normalized } });
|
||||
if (!existing) return normalized;
|
||||
const suffix = String(storeId).slice(-4);
|
||||
const candidate = `${normalized.slice(0, 15)}${suffix}`.slice(0, 20);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user