门店账户多账号
This commit is contained in:
@@ -7,9 +7,22 @@ import { request, type Paginated } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
||||
|
||||
type Row = {
|
||||
id: string; phone: string; name: string; status: string; createdAt: string;
|
||||
store?: { id: string; name: string; status: string; cityName: string };
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
storeCount?: number;
|
||||
staffCount?: number;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
store?: StoreBrief | null;
|
||||
stores?: StoreBrief[];
|
||||
staff?: Array<{ id: string; name: string; phone: string; status: string; storeIds?: string[] }>;
|
||||
};
|
||||
|
||||
type StoreOption = { id: string; name: string };
|
||||
@@ -41,17 +54,53 @@ export default function StoreAccountsPage() {
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
|
||||
{ title: '门店状态', dataIndex: ['store', 'status'], width: 90, render: (s) => s ? <Tag>{STORE_STATUS_LABELS[s] || s}</Tag> : '—' },
|
||||
{ title: '账号状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{
|
||||
title: '绑定门店',
|
||||
width: 180,
|
||||
render: (_, row) =>
|
||||
row.stores?.length
|
||||
? row.stores.map((s) => s.name).join('、')
|
||||
: row.store?.name ?? '—',
|
||||
},
|
||||
{
|
||||
title: '门店数',
|
||||
dataIndex: 'storeCount',
|
||||
width: 70,
|
||||
render: (n, row) => n ?? row.stores?.length ?? 0,
|
||||
},
|
||||
{
|
||||
title: '子账号',
|
||||
dataIndex: 'staffCount',
|
||||
width: 70,
|
||||
render: (n) => n ?? 0,
|
||||
},
|
||||
{
|
||||
title: '收款户名',
|
||||
dataIndex: 'bankAccountName',
|
||||
width: 120,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '账号状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/store-accounts/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/store-accounts/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -61,48 +110,141 @@ export default function StoreAccountsPage() {
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
||||
<Typography.Text type="secondary">新建门店时自动开通主账号;「新建账户」仅用于补录历史无账号门店</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新建账户</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
void loadStores();
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建账户
|
||||
</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="门店账户" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Select defaultValue={detail.status} style={{ width: 100 }}
|
||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
await request(`/admin/store-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify({ status }) });
|
||||
message.success('已更新');
|
||||
void reload();
|
||||
}} />
|
||||
)}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer
|
||||
title="门店主账号"
|
||||
width={520}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail && (
|
||||
<Select
|
||||
defaultValue={detail.status}
|
||||
style={{ width: 100 }}
|
||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
await request(`/admin/store-accounts/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
message.success('已更新');
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款户名">{detail.bankAccountName || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款账号">{detail.bankAccountNo || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开户行">{detail.bankBranch || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定门店">
|
||||
{(detail.stores ?? []).map((s) => (
|
||||
<Tag key={s.id}>
|
||||
{s.name}
|
||||
{s.status ? `(${STORE_STATUS_LABELS[s.status] || s.status})` : ''}
|
||||
</Tag>
|
||||
))}
|
||||
{!detail.stores?.length ? '—' : null}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{detail.staff?.length ? (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 24 }}>子账号</Typography.Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={detail.staff}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '手机', dataIndex: 'phone' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建门店账户" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}>
|
||||
<Modal
|
||||
title="新建门店账户"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={stores.map((s) => ({ value: s.id, label: s.name }))} />
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={stores.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
|
||||
@@ -310,6 +310,16 @@ export default function StoreCreatePage() {
|
||||
setFieldErrors({ phone: phoneMsg });
|
||||
return;
|
||||
}
|
||||
if (phoneCheck.needConfirm) {
|
||||
const ok = window.confirm(
|
||||
phoneCheck.message ??
|
||||
`该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`,
|
||||
);
|
||||
if (!ok) {
|
||||
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||
@@ -381,6 +391,8 @@ export default function StoreCreatePage() {
|
||||
|
||||
setFieldErrors({});
|
||||
|
||||
let confirmBindExisting = false;
|
||||
|
||||
try {
|
||||
|
||||
const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
|
||||
@@ -397,6 +409,18 @@ export default function StoreCreatePage() {
|
||||
|
||||
}
|
||||
|
||||
if (phoneCheck.needConfirm) {
|
||||
const ok = window.confirm(
|
||||
phoneCheck.message ??
|
||||
`该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`,
|
||||
);
|
||||
if (!ok) {
|
||||
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
|
||||
return;
|
||||
}
|
||||
confirmBindExisting = true;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||
return;
|
||||
@@ -440,6 +464,8 @@ export default function StoreCreatePage() {
|
||||
|
||||
bankBranch: form.bankBranch.trim(),
|
||||
|
||||
...(confirmBindExisting ? { confirmBindExisting: true } : {}),
|
||||
|
||||
}),
|
||||
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import AuthGate from './components/AuthGate';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import SelectStorePage from './pages/SelectStorePage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import RedeemConfirmPage from './pages/RedeemConfirmPage';
|
||||
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
||||
@@ -9,12 +10,15 @@ import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||
import RecordsPage from './pages/RecordsPage';
|
||||
import StatusPage from './pages/StatusPage';
|
||||
import MinePage from './pages/MinePage';
|
||||
import StaffPage from './pages/StaffPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthGate>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/select-store" element={<SelectStorePage />} />
|
||||
<Route path="/staff" element={<StaffPage />} />
|
||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||
|
||||
@@ -3,9 +3,10 @@ import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login']);
|
||||
const SELECT_STORE_PATH = '/select-store';
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const { ready, authenticated } = useStoreSession();
|
||||
const { ready, authenticated, needsSelectStore } = useStoreSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (!ready) {
|
||||
@@ -17,7 +18,11 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
if (authenticated && location.pathname === '/login') {
|
||||
return <Navigate to="/" replace />;
|
||||
return <Navigate to={needsSelectStore ? SELECT_STORE_PATH : '/'} replace />;
|
||||
}
|
||||
|
||||
if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) {
|
||||
return <Navigate to={SELECT_STORE_PATH} replace />;
|
||||
}
|
||||
|
||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type StoreSessionContextValue = {
|
||||
ready: boolean;
|
||||
authenticated: boolean;
|
||||
needsSelectStore: boolean;
|
||||
store: StoreSessionStore | null;
|
||||
applySession: (session: ShopSessionPayload) => void;
|
||||
resetSession: () => void;
|
||||
@@ -28,17 +29,29 @@ const StoreSessionContext = createContext<StoreSessionContextValue | null>(null)
|
||||
export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [needsSelectStore, setNeedsSelectStore] = 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 nextStore = session.store
|
||||
? {
|
||||
...session.store,
|
||||
stores: session.stores ?? session.store.stores,
|
||||
isPrimary: session.account?.isPrimary ?? session.store.isPrimary,
|
||||
}
|
||||
: null;
|
||||
setStore(nextStore);
|
||||
const storeId = nextStore?.storeId || session.selectedStoreId || '';
|
||||
const stores = session.stores ?? nextStore?.stores ?? [];
|
||||
setNeedsSelectStore(stores.length > 1 && !storeId);
|
||||
}, []);
|
||||
|
||||
const resetSession = useCallback(() => {
|
||||
clearAuth();
|
||||
setAuthenticated(false);
|
||||
setNeedsSelectStore(false);
|
||||
setStore(null);
|
||||
}, []);
|
||||
|
||||
@@ -50,6 +63,7 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
||||
if (cancelled) return;
|
||||
setAuthenticated(result.authenticated);
|
||||
setStore(result.store);
|
||||
setNeedsSelectStore(result.needsSelectStore);
|
||||
} catch {
|
||||
if (!cancelled) resetSession();
|
||||
} finally {
|
||||
@@ -62,8 +76,8 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
||||
}, [resetSession]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ ready, authenticated, store, applySession, resetSession }),
|
||||
[ready, authenticated, store, applySession, resetSession],
|
||||
() => ({ ready, authenticated, needsSelectStore, store, applySession, resetSession }),
|
||||
[ready, authenticated, needsSelectStore, store, applySession, resetSession],
|
||||
);
|
||||
|
||||
return <StoreSessionContext.Provider value={value}>{children}</StoreSessionContext.Provider>;
|
||||
|
||||
+100
-17
@@ -1,12 +1,22 @@
|
||||
export const apiBase = '/api/v1';
|
||||
const CLIENT_APP = 'SHOP_H5';
|
||||
|
||||
export type ShopStoreOption = {
|
||||
storeId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
export type StoreSessionStore = {
|
||||
id: string;
|
||||
storeId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
storeName: string;
|
||||
isPrimary?: boolean;
|
||||
stores?: ShopStoreOption[];
|
||||
};
|
||||
|
||||
export type StoreProfile = {
|
||||
@@ -15,13 +25,25 @@ export type StoreProfile = {
|
||||
name: string;
|
||||
phone: string;
|
||||
status?: string;
|
||||
store?: { id: string; name: string };
|
||||
isPrimary?: boolean;
|
||||
account?: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
isPrimary: boolean;
|
||||
hasWechat?: boolean;
|
||||
};
|
||||
store?: ShopStoreOption | { id: string; name: string } | null;
|
||||
stores?: ShopStoreOption[];
|
||||
};
|
||||
|
||||
export type ShopSessionPayload = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
store?: StoreSessionStore;
|
||||
stores?: ShopStoreOption[];
|
||||
account?: StoreProfile['account'];
|
||||
selectedStoreId?: string;
|
||||
};
|
||||
|
||||
const ACCESS_TOKEN = 'accessToken';
|
||||
@@ -73,8 +95,27 @@ 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);
|
||||
const profile: StoreSessionStore = {
|
||||
...data.store,
|
||||
stores: data.stores ?? data.store.stores,
|
||||
isPrimary: data.account?.isPrimary ?? data.store.isPrimary,
|
||||
};
|
||||
localStorage.setItem(STORE_PROFILE, JSON.stringify(profile));
|
||||
if (data.store.phone) localStorage.setItem(LAST_PHONE, data.store.phone);
|
||||
} else if (data.account) {
|
||||
localStorage.setItem(
|
||||
STORE_PROFILE,
|
||||
JSON.stringify({
|
||||
id: data.account.id,
|
||||
storeId: '',
|
||||
name: data.account.name,
|
||||
phone: data.account.phone,
|
||||
storeName: '',
|
||||
isPrimary: data.account.isPrimary,
|
||||
stores: data.stores ?? [],
|
||||
} satisfies StoreSessionStore),
|
||||
);
|
||||
localStorage.setItem(LAST_PHONE, data.account.phone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,15 +142,45 @@ export function isLoggedIn() {
|
||||
}
|
||||
|
||||
function profileFromMe(me: StoreProfile): StoreSessionStore {
|
||||
const selected =
|
||||
me.store && 'storeId' in me.store
|
||||
? me.store
|
||||
: me.store && 'id' in me.store
|
||||
? { storeId: String((me.store as { id: string }).id), name: me.store.name }
|
||||
: null;
|
||||
return {
|
||||
id: me.id,
|
||||
storeId: me.storeId,
|
||||
name: me.name,
|
||||
phone: me.phone,
|
||||
storeName: me.store?.name ?? me.name,
|
||||
id: me.account?.id ?? me.id,
|
||||
storeId: selected?.storeId ?? me.storeId ?? '',
|
||||
name: me.account?.name ?? me.name,
|
||||
phone: me.account?.phone ?? me.phone,
|
||||
storeName: selected?.name ?? '',
|
||||
isPrimary: me.account?.isPrimary ?? me.isPrimary,
|
||||
stores: me.stores ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function needsStoreSelection(session: {
|
||||
store?: StoreSessionStore | null;
|
||||
stores?: ShopStoreOption[];
|
||||
selectedStoreId?: string;
|
||||
}): boolean {
|
||||
const storeId = session.store?.storeId || session.selectedStoreId || '';
|
||||
const stores = session.stores ?? session.store?.stores ?? [];
|
||||
if (stores.length > 1 && !storeId) return true;
|
||||
if (!storeId && stores.length !== 1) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function selectStore(storeId: string): Promise<ShopSessionPayload> {
|
||||
const data = await requestWithAuthRetry<ShopSessionPayload>('/shop/auth/select-store', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ storeId }),
|
||||
});
|
||||
saveAuth(data);
|
||||
touchShopSession();
|
||||
return data;
|
||||
}
|
||||
|
||||
async function rawRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
@@ -190,13 +261,17 @@ export async function request<T>(clientApp: string, path: string, options: Reque
|
||||
return requestWithAuthRetry<T>(path, options);
|
||||
}
|
||||
|
||||
export async function ensureSession(): Promise<{ authenticated: boolean; store: StoreSessionStore | null }> {
|
||||
export async function ensureSession(): Promise<{
|
||||
authenticated: boolean;
|
||||
store: StoreSessionStore | null;
|
||||
needsSelectStore: boolean;
|
||||
}> {
|
||||
if (!isLoggedIn()) {
|
||||
return { authenticated: false, store: null };
|
||||
return { authenticated: false, store: null, needsSelectStore: false };
|
||||
}
|
||||
if (isShopSessionExpired()) {
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, store: getStoreProfile() };
|
||||
return { authenticated: false, store: getStoreProfile(), needsSelectStore: false };
|
||||
}
|
||||
try {
|
||||
const me = await rawRequest<StoreProfile>('/shop/auth/me');
|
||||
@@ -205,21 +280,29 @@ export async function ensureSession(): Promise<{ authenticated: boolean; store:
|
||||
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||
store,
|
||||
stores: me.stores,
|
||||
account: me.account,
|
||||
});
|
||||
touchShopSession();
|
||||
return { authenticated: true, store };
|
||||
return {
|
||||
authenticated: true,
|
||||
store,
|
||||
needsSelectStore: needsStoreSelection({ store, stores: me.stores }),
|
||||
};
|
||||
} 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 };
|
||||
if (refreshed) {
|
||||
return {
|
||||
authenticated: true,
|
||||
store: refreshed.store ?? getStoreProfile(),
|
||||
needsSelectStore: needsStoreSelection(refreshed),
|
||||
};
|
||||
}
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, store: getStoreProfile() };
|
||||
return { authenticated: false, store: getStoreProfile(), needsSelectStore: false };
|
||||
}
|
||||
const cached = getStoreProfile();
|
||||
if (cached) return { authenticated: true, store: cached };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,17 +36,26 @@ export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null):
|
||||
|
||||
export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPayload | null {
|
||||
if (!result.accessToken || !result.refreshToken) return null;
|
||||
const store = result.store;
|
||||
const store = result.store as Record<string, unknown> | undefined;
|
||||
const stores = Array.isArray((result as { stores?: unknown }).stores)
|
||||
? ((result as { stores: ShopSessionPayload['stores'] }).stores)
|
||||
: undefined;
|
||||
const account = (result as { account?: ShopSessionPayload['account'] }).account;
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
stores,
|
||||
account,
|
||||
selectedStoreId: (result as { selectedStoreId?: string }).selectedStoreId,
|
||||
store: store
|
||||
? {
|
||||
id: String(store.id ?? ''),
|
||||
id: String(store.id ?? account?.id ?? ''),
|
||||
storeId: String(store.storeId ?? ''),
|
||||
name: String(store.name ?? ''),
|
||||
phone: String(store.phone ?? ''),
|
||||
name: String(store.name ?? account?.name ?? ''),
|
||||
phone: String(store.phone ?? account?.phone ?? ''),
|
||||
storeName: String(store.storeName ?? store.name ?? ''),
|
||||
isPrimary: account?.isPrimary,
|
||||
stores,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
|
||||
import { routeAfterShopLogin } from './SelectStorePage';
|
||||
import {
|
||||
bindShopWechatAfterSmsLogin,
|
||||
fetchClientConfig,
|
||||
@@ -58,7 +59,7 @@ export default function LoginPage() {
|
||||
applySession(session);
|
||||
stripOAuthParamsFromLocation();
|
||||
setSearchParams({}, { replace: true });
|
||||
navigate('/');
|
||||
routeAfterShopLogin(session, navigate);
|
||||
}
|
||||
})
|
||||
.catch((e) => setMsg(formatWechatError(e)));
|
||||
@@ -115,7 +116,7 @@ export default function LoginPage() {
|
||||
await bindShopWechatAfterSmsLogin();
|
||||
return;
|
||||
}
|
||||
navigate('/');
|
||||
routeAfterShopLogin(data, navigate);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
@@ -135,7 +136,7 @@ export default function LoginPage() {
|
||||
const session = await loginShopWithWechat();
|
||||
if (session) {
|
||||
applySession(session);
|
||||
navigate('/');
|
||||
routeAfterShopLogin(session, navigate);
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg(formatWechatError(e));
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { request } from '../lib/api';
|
||||
import { getStoreProfile, request } from '../lib/api';
|
||||
|
||||
export default function MinePage() {
|
||||
const navigate = useNavigate();
|
||||
const { resetSession } = useStoreSession();
|
||||
const { resetSession, store: sessionStore } = useStoreSession();
|
||||
const profile = sessionStore ?? getStoreProfile();
|
||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request('SHOP_H5', '/shop/store').then(setStore);
|
||||
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null));
|
||||
}, []);
|
||||
|
||||
const openTime = String(store?.openTime || '09:30');
|
||||
const closeTime = String(store?.closeTime || '22:00');
|
||||
const multiStore = (profile?.stores?.length ?? 0) > 1;
|
||||
|
||||
return (
|
||||
<div className="shop-mine-page">
|
||||
@@ -28,7 +30,7 @@ export default function MinePage() {
|
||||
<div className="shop-mine-info-row">
|
||||
<div>
|
||||
<p className="shop-mine-info-label">门店名称</p>
|
||||
<p className="shop-mine-info-value name">{String(store?.name || '—')}</p>
|
||||
<p className="shop-mine-info-value name">{String(store?.name || profile?.storeName || '—')}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
||||
</div>
|
||||
@@ -42,7 +44,7 @@ export default function MinePage() {
|
||||
<div className="shop-mine-info-row">
|
||||
<div>
|
||||
<p className="shop-mine-info-label">联系电话</p>
|
||||
<p className="shop-mine-info-value">{String(store?.phone || '—')}</p>
|
||||
<p className="shop-mine-info-value">{String(store?.phone || profile?.phone || '—')}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
||||
</div>
|
||||
@@ -55,6 +57,21 @@ export default function MinePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shop-mine-actions">
|
||||
{multiStore ? (
|
||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/select-store')}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>swap_horiz</span>
|
||||
切换门店
|
||||
</button>
|
||||
) : null}
|
||||
{profile?.isPrimary ? (
|
||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/staff')}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>group</span>
|
||||
子账号管理
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="shop-mine-help">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>help</span>
|
||||
<p>如需修改信息请联系城市合伙人</p>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import {
|
||||
needsStoreSelection,
|
||||
request,
|
||||
selectStore,
|
||||
type ShopSessionPayload,
|
||||
type ShopStoreOption,
|
||||
} from '../lib/api';
|
||||
|
||||
export default function SelectStorePage() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession, store, authenticated } = useStoreSession();
|
||||
const [stores, setStores] = useState<ShopStoreOption[]>(store?.stores ?? []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) {
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
void request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
|
||||
.then((list) => setStores(list))
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
|
||||
}, [authenticated, navigate]);
|
||||
|
||||
async function onSelect(storeId: string) {
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const session = await selectStore(storeId);
|
||||
applySession(session);
|
||||
navigate('/', { replace: true });
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '选店失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 仅一家店时自动选
|
||||
useEffect(() => {
|
||||
if (stores.length === 1 && needsStoreSelection({ store, stores })) {
|
||||
void onSelect(stores[0].storeId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stores.length]);
|
||||
|
||||
return (
|
||||
<div className="shop-select-store-page">
|
||||
<header className="shop-select-store-header">
|
||||
<h1>选择门店</h1>
|
||||
<p>该账号绑定了多家门店,请选择本次要进入的门店</p>
|
||||
</header>
|
||||
{msg ? <p className="shop-select-store-msg">{msg}</p> : null}
|
||||
<ul className="shop-select-store-list">
|
||||
{stores.map((item) => (
|
||||
<li key={item.storeId}>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-select-store-item"
|
||||
disabled={loading}
|
||||
onClick={() => void onSelect(item.storeId)}
|
||||
>
|
||||
<span className="shop-select-store-name">{item.name}</span>
|
||||
<span className="shop-select-store-meta">
|
||||
{[item.district, item.address].filter(Boolean).join(' · ') || item.status}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!stores.length && !msg ? <p className="shop-select-store-empty">暂无绑定门店</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** After login/wechat: route to select-store or home */
|
||||
export function routeAfterShopLogin(
|
||||
session: ShopSessionPayload,
|
||||
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
||||
) {
|
||||
if (needsStoreSelection(session)) {
|
||||
navigate('/select-store', { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-types';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getStoreProfile, request } from '../lib/api';
|
||||
|
||||
type StaffItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
staffRole: StoreStaffRole;
|
||||
status: string;
|
||||
storeIds: string[];
|
||||
stores: Array<{ storeId: string; name: string }>;
|
||||
};
|
||||
|
||||
type StoreOption = { storeId: string; name: string };
|
||||
|
||||
export default function StaffPage() {
|
||||
const navigate = useNavigate();
|
||||
const { store } = useStoreSession();
|
||||
const profile = store ?? getStoreProfile();
|
||||
const [list, setList] = useState<StaffItem[]>([]);
|
||||
const [ownedStores, setOwnedStores] = useState<StoreOption[]>([]);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ phone: '', name: '', storeIds: [] as string[] });
|
||||
|
||||
async function reload() {
|
||||
const [staff, stores] = await Promise.all([
|
||||
request<StaffItem[]>('SHOP_H5', '/shop/staff'),
|
||||
request<StoreOption[]>('SHOP_H5', '/shop/auth/stores'),
|
||||
]);
|
||||
setList(staff);
|
||||
setOwnedStores(stores);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile?.isPrimary) {
|
||||
navigate('/mine', { replace: true });
|
||||
return;
|
||||
}
|
||||
void reload().catch((e) => setMsg(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [navigate, profile?.isPrimary]);
|
||||
|
||||
async function createStaff() {
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/staff', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
phone: form.phone.trim(),
|
||||
name: form.name.trim(),
|
||||
storeIds: form.storeIds.length ? form.storeIds : ownedStores.map((s) => s.storeId),
|
||||
}),
|
||||
});
|
||||
setShowForm(false);
|
||||
setForm({ phone: '', name: '', storeIds: [] });
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '创建失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: StaffItem) {
|
||||
const next = item.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE';
|
||||
try {
|
||||
await request('SHOP_H5', `/shop/staff/${item.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStoreId(storeId: string) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
storeIds: prev.storeIds.includes(storeId)
|
||||
? prev.storeIds.filter((id) => id !== storeId)
|
||||
: [...prev.storeIds, storeId],
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shop-staff-page">
|
||||
<header className="shop-staff-header">
|
||||
<button type="button" className="shop-staff-back" onClick={() => navigate('/mine')}>
|
||||
返回
|
||||
</button>
|
||||
<h1>子账号管理</h1>
|
||||
<button type="button" className="shop-staff-add" onClick={() => setShowForm((v) => !v)}>
|
||||
{showForm ? '取消' : '添加'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{msg ? <p className="shop-staff-msg">{msg}</p> : null}
|
||||
|
||||
{showForm ? (
|
||||
<div className="shop-staff-form">
|
||||
<input
|
||||
placeholder="手机号"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
placeholder="姓名"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
<p className="shop-staff-form-label">绑定门店</p>
|
||||
<div className="shop-staff-store-picks">
|
||||
{ownedStores.map((s) => (
|
||||
<label key={s.storeId}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.storeIds.includes(s.storeId) || form.storeIds.length === 0}
|
||||
onChange={() => toggleStoreId(s.storeId)}
|
||||
/>
|
||||
{s.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={() => void createStaff()}>
|
||||
创建子账号
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ul className="shop-staff-list">
|
||||
{list.map((item) => (
|
||||
<li key={item.id} className="shop-staff-item">
|
||||
<div>
|
||||
<strong>{item.name}</strong>
|
||||
<span>{item.phone}</span>
|
||||
<span>
|
||||
{STORE_STAFF_ROLE_LABELS[item.staffRole] ?? item.staffRole} ·{' '}
|
||||
{item.status === 'ACTIVE' ? '启用' : '停用'}
|
||||
</span>
|
||||
<span>{item.stores.map((s) => s.name).join('、')}</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => void toggleStatus(item)}>
|
||||
{item.status === 'ACTIVE' ? '停用' : '启用'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!list.length ? <p className="shop-staff-empty">暂无子账号</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2183,3 +2183,163 @@
|
||||
color: var(--color-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shop-select-store-page,
|
||||
.shop-staff-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px 16px 40px;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.shop-select-store-header h1,
|
||||
.shop-staff-header h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.shop-select-store-header p {
|
||||
margin: 8px 0 20px;
|
||||
color: var(--color-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shop-select-store-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shop-select-store-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shop-select-store-name {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shop-select-store-meta {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.shop-select-store-msg,
|
||||
.shop-staff-msg {
|
||||
color: var(--color-heritage-red);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shop-mine-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.shop-mine-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.shop-staff-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.shop-staff-back,
|
||||
.shop-staff-add {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-heritage-red);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shop-staff-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.shop-staff-form input {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.shop-staff-store-picks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shop-staff-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shop-staff-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.shop-staff-item div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.shop-staff-item strong {
|
||||
color: #222;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.shop-staff-item button {
|
||||
align-self: center;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,11 @@ export enum PartnerStaffRole {
|
||||
PROMOTER = 'PROMOTER',
|
||||
}
|
||||
|
||||
export enum StoreStaffRole {
|
||||
MANAGER = 'MANAGER',
|
||||
CASHIER = 'CASHIER',
|
||||
}
|
||||
|
||||
export enum CityPartnerScopeType {
|
||||
CITY_WIDE = 'CITY_WIDE',
|
||||
DISTRICT = 'DISTRICT',
|
||||
|
||||
@@ -15,5 +15,6 @@ export * from './partner-log';
|
||||
export * from './promo';
|
||||
export * from './hq-permissions';
|
||||
export * from './partner';
|
||||
export * from './shop';
|
||||
export * from './city-partner';
|
||||
export * from './city-warehouse';
|
||||
|
||||
@@ -63,6 +63,9 @@ export interface PartnerLeaderboardResponse {
|
||||
export interface PartnerStorePhoneAvailableResponse {
|
||||
available: boolean;
|
||||
message?: string;
|
||||
/** 该手机号已是主账号时绑定的门店数;>0 时拓店需二次确认 */
|
||||
existingStoreCount?: number;
|
||||
needConfirm?: boolean;
|
||||
}
|
||||
|
||||
export interface PartnerWeeklyReportPeriod {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AccountStatus, StoreStaffRole } from './enums';
|
||||
|
||||
export interface ShopStoreOption {
|
||||
storeId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface ShopAccountMe {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
isPrimary: boolean;
|
||||
staffRole?: StoreStaffRole | null;
|
||||
permissions?: string[];
|
||||
primaryAccountId?: string;
|
||||
hasWechat?: boolean;
|
||||
}
|
||||
|
||||
export interface ShopMe {
|
||||
account: ShopAccountMe;
|
||||
store: ShopStoreOption | null;
|
||||
stores: ShopStoreOption[];
|
||||
/** @deprecated use account + store; kept for older H5 clients during rollout */
|
||||
id?: string;
|
||||
storeId?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface ShopLoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
actorType: string;
|
||||
actorId: string;
|
||||
phoneVerified: boolean;
|
||||
account: ShopAccountMe;
|
||||
stores: ShopStoreOption[];
|
||||
store?: ShopStoreOption | null;
|
||||
/** populated after select-store (or auto when single store) */
|
||||
selectedStoreId?: string;
|
||||
}
|
||||
|
||||
export interface SelectShopStoreRequest {
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
export interface ShopStaffItem {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
staffRole: StoreStaffRole;
|
||||
permissions?: string[];
|
||||
status: AccountStatus;
|
||||
storeIds: string[];
|
||||
stores: ShopStoreOption[];
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateShopStaffRequest {
|
||||
phone: string;
|
||||
name: string;
|
||||
storeIds: string[];
|
||||
staffRole?: StoreStaffRole;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateShopStaffRequest {
|
||||
name?: string;
|
||||
staffRole?: StoreStaffRole;
|
||||
permissions?: string[];
|
||||
status?: AccountStatus;
|
||||
storeIds?: string[];
|
||||
}
|
||||
|
||||
export const STORE_STAFF_ROLE_LABELS: Record<StoreStaffRole, string> = {
|
||||
MANAGER: '店长',
|
||||
CASHIER: '收银员',
|
||||
};
|
||||
|
||||
/** Default permissions for store sub-accounts (首版). */
|
||||
export const STORE_STAFF_DEFAULT_PERMISSIONS = ['redeem', 'records'] as const;
|
||||
@@ -147,6 +147,11 @@ enum PartnerStaffRole {
|
||||
PROMOTER
|
||||
}
|
||||
|
||||
enum StoreStaffRole {
|
||||
MANAGER
|
||||
CASHIER
|
||||
}
|
||||
|
||||
enum AccountStatus {
|
||||
ACTIVE
|
||||
DISABLED
|
||||
@@ -697,9 +702,6 @@ model Store {
|
||||
status StoreStatus @default(PAUSED)
|
||||
openTime String? @map("open_time") @db.VarChar(8)
|
||||
closeTime String? @map("close_time") @db.VarChar(8)
|
||||
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
|
||||
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
|
||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
@@ -708,7 +710,7 @@ model Store {
|
||||
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
|
||||
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
|
||||
account StoreAccount?
|
||||
bindings StoreAccountStore[]
|
||||
redeemRecords RedeemRecord[]
|
||||
redeemPendingRecords RedeemPendingRecord[]
|
||||
ratings StoreRating[]
|
||||
@@ -720,23 +722,46 @@ model Store {
|
||||
}
|
||||
|
||||
model StoreAccount {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
storeId BigInt @unique @map("store_id") @db.UnsignedBigInt
|
||||
phone String @unique @db.VarChar(20)
|
||||
name String @db.VarChar(64)
|
||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
||||
status AccountStatus @default(ACTIVE)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
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
|
||||
phone String @unique @db.VarChar(20)
|
||||
name String @db.VarChar(64)
|
||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
||||
isPrimary Int @default(1) @map("is_primary") @db.TinyInt
|
||||
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
|
||||
staffRole StoreStaffRole? @map("staff_role")
|
||||
permissions Json?
|
||||
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
|
||||
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
|
||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||
status AccountStatus @default(ACTIVE)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
parentAccount StoreAccount? @relation("StoreAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: Restrict)
|
||||
childAccounts StoreAccount[] @relation("StoreAccountHierarchy")
|
||||
bindings StoreAccountStore[]
|
||||
redeemPendingRecords RedeemPendingRecord[]
|
||||
|
||||
@@index([parentAccountId])
|
||||
@@map("store_account")
|
||||
}
|
||||
|
||||
model StoreAccountStore {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
storeAccountId BigInt @map("store_account_id") @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
storeAccount StoreAccount @relation(fields: [storeAccountId], references: [id], onDelete: Cascade)
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([storeAccountId, storeId])
|
||||
@@index([storeId])
|
||||
@@map("store_account_store")
|
||||
}
|
||||
|
||||
// ─── ORDER ────────────────────────────────────────────
|
||||
|
||||
model Order {
|
||||
|
||||
@@ -429,12 +429,6 @@ async function main() {
|
||||
|
||||
closeTime: '22:00',
|
||||
|
||||
bankAccountName: def.name,
|
||||
|
||||
bankAccountNo: '6222029876543210',
|
||||
|
||||
bankBranch: '建设银行郑州分行',
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
@@ -443,20 +437,41 @@ async function main() {
|
||||
|
||||
await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
|
||||
|
||||
if (def.withAccount) {
|
||||
|
||||
await prisma.storeAccount.create({
|
||||
|
||||
data: { storeId: store.id, phone: def.phone, name: def.name },
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
createdStores.push({ id: store.id, name: def.name });
|
||||
|
||||
}
|
||||
|
||||
// 一号两店:主账号 13910000001 绑定老城店 + 美食城店,便于测选店
|
||||
const multiStorePrimary = await prisma.storeAccount.create({
|
||||
data: {
|
||||
phone: '13910000001',
|
||||
name: '郑州老城店主',
|
||||
isPrimary: 1,
|
||||
bankAccountName: '郑州老城店主',
|
||||
bankAccountNo: '6222029876543210',
|
||||
bankBranch: '建设银行郑州分行',
|
||||
bindings: {
|
||||
create: createdStores.map((s) => ({ storeId: s.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 子账号样例:仅绑定第一家店
|
||||
await prisma.storeAccount.create({
|
||||
data: {
|
||||
phone: '13910000011',
|
||||
name: '老城店收银员',
|
||||
isPrimary: 0,
|
||||
parentAccountId: multiStorePrimary.id,
|
||||
staffRole: 'CASHIER',
|
||||
permissions: ['redeem', 'records'],
|
||||
status: 'ACTIVE',
|
||||
bindings: {
|
||||
create: [{ storeId: createdStores[0].id }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
const weekStart = (() => {
|
||||
@@ -511,12 +526,6 @@ async function main() {
|
||||
|
||||
closeTime: '22:00',
|
||||
|
||||
bankAccountName: '本周新签体验店',
|
||||
|
||||
bankAccountNo: '6222029876543211',
|
||||
|
||||
bankBranch: '农业银行郑州分行',
|
||||
|
||||
createdAt: new Date(weekStart.getTime() + 2 * 24 * 60 * 60 * 1000),
|
||||
|
||||
},
|
||||
@@ -525,6 +534,18 @@ async function main() {
|
||||
|
||||
createdStores.push({ id: newWeekStore.id, name: newWeekStore.name });
|
||||
|
||||
await prisma.storeAccount.create({
|
||||
data: {
|
||||
phone: '13910000003',
|
||||
name: '本周新签体验店',
|
||||
isPrimary: 1,
|
||||
bankAccountName: '本周新签体验店',
|
||||
bankAccountNo: '6222029876543211',
|
||||
bankBranch: '农业银行郑州分行',
|
||||
bindings: { create: [{ storeId: newWeekStore.id }] },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
await prisma.user.create({
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface AuthUser {
|
||||
clientApp: ClientApp;
|
||||
sub: string;
|
||||
phoneVerified: boolean;
|
||||
/** Selected store after POST /shop/auth/select-store */
|
||||
storeId?: bigint;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -41,6 +43,9 @@ export class JwtAuthGuard implements CanActivate {
|
||||
clientApp,
|
||||
sub: payload.sub,
|
||||
phoneVerified: !!payload.phoneVerified,
|
||||
...(payload.storeId != null && payload.storeId !== ''
|
||||
? { storeId: BigInt(payload.storeId) }
|
||||
: {}),
|
||||
} satisfies AuthUser;
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
||||
@@ -29,6 +29,9 @@ export class OptionalJwtAuthGuard implements CanActivate {
|
||||
clientApp,
|
||||
sub: payload.sub,
|
||||
phoneVerified: !!payload.phoneVerified,
|
||||
...(payload.storeId != null && payload.storeId !== ''
|
||||
? { storeId: BigInt(payload.storeId) }
|
||||
: {}),
|
||||
} satisfies AuthUser;
|
||||
} catch {
|
||||
/* ignore invalid token */
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import type { AuthUser } from './jwt-auth.guard';
|
||||
|
||||
/** Shop business APIs require JWT claim storeId (after select-store). */
|
||||
@Injectable()
|
||||
export class ShopStoreGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user as AuthUser | undefined;
|
||||
if (!user?.storeId) {
|
||||
throw new ForbiddenException('请先选择门店');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import type { AuthUser } from '../guards/jwt-auth.guard';
|
||||
|
||||
@Injectable()
|
||||
export class StoreMembershipService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async assertStoreMembership(accountId: bigint, storeId: bigint) {
|
||||
const binding = await this.prisma.storeAccountStore.findUnique({
|
||||
where: {
|
||||
storeAccountId_storeId: { storeAccountId: accountId, storeId },
|
||||
},
|
||||
});
|
||||
if (!binding) {
|
||||
throw new ForbiddenException('无权访问该门店');
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
requireShopStoreId(user: AuthUser): bigint {
|
||||
if (!user.storeId) {
|
||||
throw new ForbiddenException('请先选择门店');
|
||||
}
|
||||
return user.storeId;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export type TrackEventInput = {
|
||||
|
||||
export type TrackStoreEventInput = TrackEventInput & {
|
||||
storeAccountId?: bigint;
|
||||
storeId: bigint;
|
||||
storeId?: bigint;
|
||||
};
|
||||
|
||||
export type TrackPartnerEventInput = TrackEventInput & {
|
||||
@@ -54,12 +54,14 @@ export class AnalyticsService {
|
||||
}
|
||||
|
||||
async trackStoreOne(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
|
||||
if (event.storeId == null) return;
|
||||
await this.prisma.logStoreAnalytics.create({
|
||||
data: this.toStoreRow(storeAccountId, clientApp, event),
|
||||
});
|
||||
}
|
||||
|
||||
trackStoreOneSafe(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
|
||||
if (event.storeId == null) return;
|
||||
void this.trackStoreOne(storeAccountId, clientApp, event).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -100,7 +102,7 @@ export class AnalyticsService {
|
||||
) {
|
||||
return {
|
||||
storeAccountId,
|
||||
storeId: event.storeId,
|
||||
storeId: event.storeId!,
|
||||
eventName: event.eventName,
|
||||
clientApp: clientApp as ClientApp,
|
||||
refType: event.refType,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
@@ -122,6 +122,7 @@ export class ShopAuthController {
|
||||
dto.code,
|
||||
ClientApp.SHOP_H5,
|
||||
dto.platform ?? 'h5',
|
||||
user.storeId,
|
||||
);
|
||||
}
|
||||
return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
|
||||
@@ -132,10 +133,23 @@ export class ShopAuthController {
|
||||
return this.authService.refreshAccessToken(dto.refreshToken, ClientApp.SHOP_H5);
|
||||
}
|
||||
|
||||
@Get('stores')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
stores(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.listShopStores(user.actorId);
|
||||
}
|
||||
|
||||
@Post('select-store')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
selectStore(@CurrentUser() user: AuthUser, @Body() body: { storeId: string }) {
|
||||
if (!body?.storeId) throw new BadRequestException('请选择门店');
|
||||
return this.authService.selectShopStore(user.actorId, BigInt(body.storeId), ClientApp.SHOP_H5);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.getMe(user.actorType, user.actorId);
|
||||
return this.authService.getShopMe(user);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,12 +172,13 @@ export class AuthService {
|
||||
|
||||
private trackStoreEvent(
|
||||
storeAccountId: bigint | undefined,
|
||||
storeId: bigint,
|
||||
storeId: bigint | undefined,
|
||||
clientApp: ClientApp | string,
|
||||
eventName: string,
|
||||
extraJson?: Record<string, unknown>,
|
||||
ref?: { refType?: string; refId?: bigint },
|
||||
) {
|
||||
if (storeId == null) return;
|
||||
this.analyticsService.trackStoreOneSafe(storeAccountId, clientApp, {
|
||||
storeId,
|
||||
eventName,
|
||||
@@ -393,14 +394,23 @@ export class AuthService {
|
||||
if (scene === SmsScene.STORE_LOGIN && actorRef?.refType === 'STORE') {
|
||||
const storeAccount = await this.prisma.storeAccount.findUnique({
|
||||
where: { id: actorRef.refId },
|
||||
select: { id: true, storeId: true },
|
||||
select: {
|
||||
id: true,
|
||||
bindings: { select: { storeId: true }, take: 1 },
|
||||
},
|
||||
});
|
||||
if (storeAccount) {
|
||||
this.trackStoreEvent(storeAccount.id, storeAccount.storeId, clientApp, 'store_sms_send', {
|
||||
scene,
|
||||
phone: this.maskPhone(normalizedPhone),
|
||||
status: 'success',
|
||||
});
|
||||
this.trackStoreEvent(
|
||||
storeAccount.id,
|
||||
storeAccount.bindings[0]?.storeId,
|
||||
clientApp,
|
||||
'store_sms_send',
|
||||
{
|
||||
scene,
|
||||
phone: this.maskPhone(normalizedPhone),
|
||||
status: 'success',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -487,7 +497,11 @@ export class AuthService {
|
||||
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||
}
|
||||
if (payload.actorType === 'STORE' && clientApp === ClientApp.SHOP_H5) {
|
||||
return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp);
|
||||
const storeId =
|
||||
payload.storeId != null && payload.storeId !== ''
|
||||
? BigInt(payload.storeId)
|
||||
: undefined;
|
||||
return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp, storeId);
|
||||
}
|
||||
if (payload.actorType === 'PARTNER' && clientApp === ClientApp.PARTNER_H5) {
|
||||
return this.buildPartnerSessionResponse(BigInt(payload.actorId), clientApp);
|
||||
@@ -499,21 +513,188 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
private async buildStoreSessionResponse(accountId: bigint, clientApp: ClientApp) {
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
private async loadStoreAccountWithBindings(accountId: bigint) {
|
||||
return this.prisma.storeAccount.findUnique({
|
||||
where: { id: accountId },
|
||||
include: { store: true },
|
||||
include: {
|
||||
bindings: {
|
||||
include: {
|
||||
store: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
district: true,
|
||||
address: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private mapShopStoreOptions(
|
||||
bindings: Array<{
|
||||
store: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
status: string;
|
||||
district: string;
|
||||
address: string;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
return bindings.map((b) => ({
|
||||
storeId: b.store.id.toString(),
|
||||
name: b.store.name,
|
||||
status: b.store.status,
|
||||
district: b.store.district,
|
||||
address: b.store.address,
|
||||
}));
|
||||
}
|
||||
|
||||
private formatShopAccountMe(account: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
phone: string;
|
||||
isPrimary: number;
|
||||
staffRole: string | null;
|
||||
permissions: unknown;
|
||||
parentAccountId: bigint | null;
|
||||
wxOpenId: string | null;
|
||||
}) {
|
||||
const permissions = Array.isArray(account.permissions)
|
||||
? (account.permissions as string[])
|
||||
: undefined;
|
||||
return {
|
||||
id: account.id.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
staffRole: account.staffRole,
|
||||
permissions,
|
||||
primaryAccountId: account.parentAccountId?.toString(),
|
||||
hasWechat: !!account.wxOpenId,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildStoreSessionResponse(
|
||||
accountId: bigint,
|
||||
clientApp: ClientApp,
|
||||
preferredStoreId?: bigint,
|
||||
) {
|
||||
const account = await this.loadStoreAccountWithBindings(accountId);
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
return this.issueToken('STORE', account.id, clientApp, false, undefined, {
|
||||
return this.issueStoreSession(account, clientApp, preferredStoreId);
|
||||
}
|
||||
|
||||
private async issueStoreSession(
|
||||
account: NonNullable<Awaited<ReturnType<AuthService['loadStoreAccountWithBindings']>>>,
|
||||
clientApp: ClientApp,
|
||||
preferredStoreId?: bigint,
|
||||
options?: { autoSelectSingle?: boolean },
|
||||
) {
|
||||
const stores = this.mapShopStoreOptions(account.bindings);
|
||||
const autoSelect = options?.autoSelectSingle !== false;
|
||||
let selectedStoreId = preferredStoreId;
|
||||
if (selectedStoreId != null) {
|
||||
const ok = account.bindings.some((b) => b.store.id === selectedStoreId);
|
||||
if (!ok) throw new ForbiddenException('无权访问该门店');
|
||||
} else if (autoSelect && stores.length === 1) {
|
||||
selectedStoreId = BigInt(stores[0].storeId);
|
||||
}
|
||||
|
||||
const selected = selectedStoreId
|
||||
? account.bindings.find((b) => b.store.id === selectedStoreId)?.store
|
||||
: undefined;
|
||||
const accountMe = this.formatShopAccountMe(account);
|
||||
const storePayload = {
|
||||
id: account.id.toString(),
|
||||
storeId: account.storeId.toString(),
|
||||
storeId: selected?.id.toString() ?? '',
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
storeName: account.store.name,
|
||||
});
|
||||
storeName: selected?.name ?? '',
|
||||
isPrimary: account.isPrimary === 1,
|
||||
stores,
|
||||
};
|
||||
|
||||
return this.issueToken(
|
||||
'STORE',
|
||||
account.id,
|
||||
clientApp,
|
||||
false,
|
||||
undefined,
|
||||
storePayload,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
selectedStoreId,
|
||||
{
|
||||
account: accountMe,
|
||||
stores,
|
||||
store: selected
|
||||
? {
|
||||
storeId: selected.id.toString(),
|
||||
name: selected.name,
|
||||
status: selected.status,
|
||||
district: selected.district,
|
||||
address: selected.address,
|
||||
}
|
||||
: null,
|
||||
selectedStoreId: selectedStoreId?.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async listShopStores(accountId: bigint) {
|
||||
const account = await this.loadStoreAccountWithBindings(accountId);
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException('门店账号无效');
|
||||
}
|
||||
return this.mapShopStoreOptions(account.bindings);
|
||||
}
|
||||
|
||||
async selectShopStore(accountId: bigint, storeId: bigint, clientApp: ClientApp) {
|
||||
const account = await this.loadStoreAccountWithBindings(accountId);
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException('门店账号无效');
|
||||
}
|
||||
const binding = account.bindings.find((b) => b.store.id === storeId);
|
||||
if (!binding) throw new ForbiddenException('无权访问该门店');
|
||||
this.trackStoreEvent(account.id, storeId, clientApp, 'store_select');
|
||||
return this.issueStoreSession(account, clientApp, storeId, { autoSelectSingle: false });
|
||||
}
|
||||
|
||||
async getShopMe(user: { actorId: bigint; storeId?: bigint }) {
|
||||
const account = await this.loadStoreAccountWithBindings(user.actorId);
|
||||
if (!account) throw new NotFoundException('门店账号不存在');
|
||||
const stores = this.mapShopStoreOptions(account.bindings);
|
||||
const selected = user.storeId
|
||||
? account.bindings.find((b) => b.store.id === user.storeId)?.store
|
||||
: undefined;
|
||||
const accountMe = this.formatShopAccountMe(account);
|
||||
return {
|
||||
account: accountMe,
|
||||
stores,
|
||||
store: selected
|
||||
? {
|
||||
storeId: selected.id.toString(),
|
||||
name: selected.name,
|
||||
status: selected.status,
|
||||
district: selected.district,
|
||||
address: selected.address,
|
||||
}
|
||||
: null,
|
||||
// legacy flat fields for older clients
|
||||
id: account.id.toString(),
|
||||
storeId: selected?.id.toString() ?? '',
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) {
|
||||
@@ -656,38 +837,42 @@ export class AuthService {
|
||||
try {
|
||||
await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN);
|
||||
} catch (err) {
|
||||
const account = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone } });
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
include: { bindings: { select: { storeId: true }, take: 1 } },
|
||||
});
|
||||
if (account) {
|
||||
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_sms_verify_fail', {
|
||||
phone: this.maskPhone(normalizedPhone),
|
||||
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
||||
});
|
||||
this.trackStoreEvent(
|
||||
account.id,
|
||||
account.bindings[0]?.storeId,
|
||||
clientApp,
|
||||
'store_sms_verify_fail',
|
||||
{
|
||||
phone: this.maskPhone(normalizedPhone),
|
||||
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
||||
},
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
include: { store: true },
|
||||
});
|
||||
const found = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone } });
|
||||
if (!found) throw new BadRequestException('该手机号未绑定门店');
|
||||
const account = await this.loadStoreAccountWithBindings(found.id);
|
||||
if (!account) throw new BadRequestException('该手机号未绑定门店');
|
||||
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
|
||||
if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店');
|
||||
await this.prisma.storeAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_sms_login', {
|
||||
const firstStoreId = account.bindings[0]?.store.id;
|
||||
this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_sms_login', {
|
||||
phone: this.maskPhone(normalizedPhone),
|
||||
});
|
||||
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_login_success', {
|
||||
this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_login_success', {
|
||||
method: 'sms',
|
||||
});
|
||||
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,
|
||||
});
|
||||
return this.issueStoreSession(account, clientApp);
|
||||
}
|
||||
|
||||
async loginPartner(phone: string, code: string, clientApp: ClientApp) {
|
||||
@@ -814,11 +999,7 @@ export class AuthService {
|
||||
return this.formatUserProfile(user);
|
||||
}
|
||||
if (actorType === 'STORE') {
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
include: { store: true },
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
return this.getShopMe({ actorId });
|
||||
}
|
||||
if (actorType === 'PARTNER') {
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
@@ -1104,7 +1285,6 @@ export class AuthService {
|
||||
|
||||
let account = await this.prisma.storeAccount.findFirst({
|
||||
where: { wxOpenId: session.openId },
|
||||
include: { store: true },
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
@@ -1118,22 +1298,21 @@ export class AuthService {
|
||||
wxUnionId: session.unionId ?? account.wxUnionId,
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
include: { store: true },
|
||||
});
|
||||
|
||||
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_wechat_login', { platform });
|
||||
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_login_success', {
|
||||
const full = await this.loadStoreAccountWithBindings(account.id);
|
||||
if (!full || !full.bindings.length) {
|
||||
throw new BadRequestException('该账号未绑定任何门店');
|
||||
}
|
||||
|
||||
const firstStoreId = full.bindings[0]?.store.id;
|
||||
this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_wechat_login', { platform });
|
||||
this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_login_success', {
|
||||
method: 'wechat',
|
||||
platform,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
return this.issueStoreSession(full, clientApp);
|
||||
}
|
||||
|
||||
async bindStoreWechat(
|
||||
@@ -1141,6 +1320,7 @@ export class AuthService {
|
||||
code: string,
|
||||
clientApp: ClientApp,
|
||||
platform: 'h5' | 'mini' = 'h5',
|
||||
currentStoreId?: bigint,
|
||||
) {
|
||||
this.assertWechatEnabled();
|
||||
const session =
|
||||
@@ -1150,7 +1330,6 @@ export class AuthService {
|
||||
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id: storeAccountId },
|
||||
include: { store: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('门店账号不存在');
|
||||
|
||||
@@ -1161,25 +1340,27 @@ export class AuthService {
|
||||
throw new BadRequestException('该微信已绑定其他门店账号');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.storeAccount.update({
|
||||
await this.prisma.storeAccount.update({
|
||||
where: { id: storeAccountId },
|
||||
data: {
|
||||
wxOpenId: session.openId,
|
||||
wxUnionId: session.unionId ?? account.wxUnionId,
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
include: { store: true },
|
||||
});
|
||||
|
||||
this.trackStoreEvent(updated.id, updated.storeId, clientApp, 'store_wechat_bind', { platform });
|
||||
const full = await this.loadStoreAccountWithBindings(storeAccountId);
|
||||
if (!full) throw new BadRequestException('门店账号不存在');
|
||||
|
||||
return this.issueToken('STORE', updated.id, clientApp, false, undefined, {
|
||||
id: updated.id.toString(),
|
||||
storeId: updated.storeId.toString(),
|
||||
name: updated.name,
|
||||
phone: updated.phone,
|
||||
storeName: updated.store.name,
|
||||
});
|
||||
this.trackStoreEvent(
|
||||
storeAccountId,
|
||||
currentStoreId ?? full.bindings[0]?.store.id,
|
||||
clientApp,
|
||||
'store_wechat_bind',
|
||||
{ platform },
|
||||
);
|
||||
|
||||
return this.issueStoreSession(full, clientApp, currentStoreId);
|
||||
}
|
||||
|
||||
async bindPartnerWechat(
|
||||
@@ -1497,6 +1678,8 @@ export class AuthService {
|
||||
partner?: Record<string, unknown>,
|
||||
deviceKey?: string | null,
|
||||
hq?: Record<string, unknown>,
|
||||
storeId?: bigint,
|
||||
shopExtra?: Record<string, unknown>,
|
||||
) {
|
||||
const payload = {
|
||||
sub: actorId.toString(),
|
||||
@@ -1504,6 +1687,7 @@ export class AuthService {
|
||||
actorId: actorId.toString(),
|
||||
clientApp,
|
||||
phoneVerified,
|
||||
...(storeId != null ? { storeId: storeId.toString() } : {}),
|
||||
};
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
const refreshExpiresIn = actorType === 'STORE' || actorType === 'PARTNER' ? '7d' : '30d';
|
||||
@@ -1519,6 +1703,7 @@ export class AuthService {
|
||||
store,
|
||||
partner,
|
||||
hq,
|
||||
...shopExtra,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { AccountStatus, StoreStaffRole } from '@dukang/shared-types';
|
||||
|
||||
export class CreateStoreStaffDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
storeIds: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(Object.values(StoreStaffRole))
|
||||
staffRole?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export class UpdateStoreStaffDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(Object.values(StoreStaffRole))
|
||||
@IsOptional()
|
||||
staffRole?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissions?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsIn(Object.values(AccountStatus))
|
||||
@IsOptional()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
storeIds?: string[];
|
||||
}
|
||||
@@ -13,12 +13,16 @@ import { UserAddressController } from './user-address.controller';
|
||||
import { UserAddressService } from './user-address.service';
|
||||
import { PartnerStaffController } from './partner-staff.controller';
|
||||
import { PartnerStaffService } from './partner-staff.service';
|
||||
import { StoreStaffController } from './store-staff.controller';
|
||||
import { StoreStaffService } from './store-staff.service';
|
||||
import { AdminAuthController } from './admin-auth.controller';
|
||||
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';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||
import { StoreMembershipService } from '../../common/guards/store-membership.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,11 +38,37 @@ import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||
ShopAuthController,
|
||||
PartnerAuthController,
|
||||
PartnerStaffController,
|
||||
StoreStaffController,
|
||||
UserProfileController,
|
||||
UserAddressController,
|
||||
AdminAuthController,
|
||||
],
|
||||
providers: [AuthService, UserAddressService, PartnerStaffService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
|
||||
exports: [AuthService, UserAddressService, PartnerStaffService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
|
||||
providers: [
|
||||
AuthService,
|
||||
UserAddressService,
|
||||
PartnerStaffService,
|
||||
StoreStaffService,
|
||||
StoreMembershipService,
|
||||
JwtAuthGuard,
|
||||
PhoneVerifiedGuard,
|
||||
OptionalJwtAuthGuard,
|
||||
HqAuthGuard,
|
||||
PartnerPrimaryGuard,
|
||||
ShopStoreGuard,
|
||||
],
|
||||
exports: [
|
||||
AuthService,
|
||||
UserAddressService,
|
||||
PartnerStaffService,
|
||||
StoreStaffService,
|
||||
StoreMembershipService,
|
||||
JwtModule,
|
||||
JwtAuthGuard,
|
||||
PhoneVerifiedGuard,
|
||||
OptionalJwtAuthGuard,
|
||||
HqAuthGuard,
|
||||
PartnerPrimaryGuard,
|
||||
ShopStoreGuard,
|
||||
],
|
||||
})
|
||||
export class IamModule {}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { StoreStaffService } from './store-staff.service';
|
||||
import { CreateStoreStaffDto, UpdateStoreStaffDto } from './dto/store-staff.dto';
|
||||
|
||||
@Controller('shop/staff')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class StoreStaffController {
|
||||
constructor(private readonly staffService: StoreStaffService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.staffService.listStaff(user.actorId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateStoreStaffDto) {
|
||||
return this.staffService.createStaff(user, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateStoreStaffDto,
|
||||
) {
|
||||
return this.staffService.updateStaff(user, BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.staffService.deleteStaff(user, BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
STORE_STAFF_DEFAULT_PERMISSIONS,
|
||||
StoreStaffRole,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { CreateStoreStaffDto, UpdateStoreStaffDto } from './dto/store-staff.dto';
|
||||
|
||||
@Injectable()
|
||||
export class StoreStaffService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly analytics: AnalyticsService,
|
||||
) {}
|
||||
|
||||
async listStaff(parentAccountId: bigint) {
|
||||
const parent = await this.assertPrimary(parentAccountId);
|
||||
const rows = await this.prisma.storeAccount.findMany({
|
||||
where: { parentAccountId: parent.id },
|
||||
include: {
|
||||
bindings: {
|
||||
include: {
|
||||
store: {
|
||||
select: { id: true, name: true, status: true, district: true, address: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toStaffItem(row));
|
||||
}
|
||||
|
||||
async createStaff(actor: AuthUser, dto: CreateStoreStaffDto) {
|
||||
const parent = await this.assertPrimary(actor.actorId);
|
||||
const phone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
const existing = await this.prisma.storeAccount.findUnique({ where: { phone } });
|
||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||
|
||||
const storeIds = await this.resolveOwnedStoreIds(parent.id, dto.storeIds);
|
||||
if (!storeIds.length) throw new BadRequestException('请至少绑定一家门店');
|
||||
|
||||
const staffRole = (dto.staffRole as StoreStaffRole | undefined) ?? StoreStaffRole.CASHIER;
|
||||
const permissions = dto.permissions?.length
|
||||
? dto.permissions
|
||||
: [...STORE_STAFF_DEFAULT_PERMISSIONS];
|
||||
|
||||
const account = await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
phone,
|
||||
name,
|
||||
isPrimary: 0,
|
||||
parentAccountId: parent.id,
|
||||
staffRole,
|
||||
permissions,
|
||||
status: 'ACTIVE',
|
||||
bindings: {
|
||||
create: storeIds.map((storeId) => ({ storeId })),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
bindings: {
|
||||
include: {
|
||||
store: {
|
||||
select: { id: true, name: true, status: true, district: true, address: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.trackStaffEvent(actor, parent.id, 'store_staff_create', account.id, {
|
||||
name,
|
||||
phone: this.maskPhone(phone),
|
||||
staffRole,
|
||||
storeIds: storeIds.map(String),
|
||||
});
|
||||
|
||||
return this.toStaffItem(account);
|
||||
}
|
||||
|
||||
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdateStoreStaffDto) {
|
||||
const parent = await this.assertPrimary(actor.actorId);
|
||||
const staff = await this.assertStaffOwned(parent.id, staffId);
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.staffRole !== undefined) data.staffRole = dto.staffRole;
|
||||
if (dto.permissions !== undefined) data.permissions = dto.permissions;
|
||||
if (dto.status !== undefined) data.status = dto.status;
|
||||
|
||||
if (dto.storeIds !== undefined) {
|
||||
const storeIds = await this.resolveOwnedStoreIds(parent.id, dto.storeIds);
|
||||
if (!storeIds.length) throw new BadRequestException('请至少绑定一家门店');
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.storeAccountStore.deleteMany({ where: { storeAccountId: staff.id } }),
|
||||
this.prisma.storeAccountStore.createMany({
|
||||
data: storeIds.map((storeId) => ({ storeAccountId: staff.id, storeId })),
|
||||
}),
|
||||
this.prisma.storeAccount.update({ where: { id: staff.id }, data }),
|
||||
]);
|
||||
} else if (Object.keys(data).length) {
|
||||
await this.prisma.storeAccount.update({ where: { id: staff.id }, data });
|
||||
}
|
||||
|
||||
const updated = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: staff.id },
|
||||
include: {
|
||||
bindings: {
|
||||
include: {
|
||||
store: {
|
||||
select: { id: true, name: true, status: true, district: true, address: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.trackStaffEvent(actor, parent.id, 'store_staff_update', staff.id, {
|
||||
name: updated.name,
|
||||
status: updated.status,
|
||||
staffRole: updated.staffRole,
|
||||
});
|
||||
|
||||
return this.toStaffItem(updated);
|
||||
}
|
||||
|
||||
async deleteStaff(actor: AuthUser, staffId: bigint) {
|
||||
const parent = await this.assertPrimary(actor.actorId);
|
||||
const staff = await this.assertStaffOwned(parent.id, staffId);
|
||||
this.trackStaffEvent(actor, parent.id, 'store_staff_delete', staff.id, {
|
||||
name: staff.name,
|
||||
phone: this.maskPhone(staff.phone),
|
||||
});
|
||||
await this.prisma.storeAccount.delete({ where: { id: staff.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async assertPrimary(accountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUnique({ where: { id: accountId } });
|
||||
if (!account) throw new NotFoundException('门店账号不存在');
|
||||
if (account.isPrimary !== 1) {
|
||||
throw new ForbiddenException('仅主账号可管理子账号');
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
|
||||
const staff = await this.prisma.storeAccount.findFirst({
|
||||
where: { id: staffId, parentAccountId },
|
||||
});
|
||||
if (!staff) throw new NotFoundException('子账号不存在');
|
||||
return staff;
|
||||
}
|
||||
|
||||
/** Staff may only bind stores that the primary account itself is bound to. */
|
||||
private async resolveOwnedStoreIds(primaryAccountId: bigint, storeIds: string[]) {
|
||||
const unique = [...new Set(storeIds.map((id) => id.trim()).filter(Boolean))];
|
||||
const ids = unique.map((id) => BigInt(id));
|
||||
const owned = await this.prisma.storeAccountStore.findMany({
|
||||
where: { storeAccountId: primaryAccountId, storeId: { in: ids } },
|
||||
select: { storeId: true },
|
||||
});
|
||||
if (owned.length !== ids.length) {
|
||||
throw new BadRequestException('只能绑定主账号已管理的门店');
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private trackStaffEvent(
|
||||
actor: AuthUser,
|
||||
primaryAccountId: bigint,
|
||||
eventName: string,
|
||||
refId: bigint,
|
||||
extraJson?: Record<string, unknown>,
|
||||
) {
|
||||
this.analytics.trackStoreOneSafe(actor.actorId, actor.clientApp, {
|
||||
storeId: actor.storeId,
|
||||
eventName,
|
||||
refType: 'STORE_ACCOUNT',
|
||||
refId,
|
||||
extraJson: { primaryAccountId: primaryAccountId.toString(), ...extraJson },
|
||||
});
|
||||
}
|
||||
|
||||
private toStaffItem(row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
phone: string;
|
||||
staffRole: string | null;
|
||||
permissions?: unknown;
|
||||
status: string;
|
||||
lastLoginAt: Date | null;
|
||||
bindings: Array<{
|
||||
store: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
status: string;
|
||||
district: string;
|
||||
address: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
phone: this.maskPhone(row.phone),
|
||||
staffRole: row.staffRole ?? StoreStaffRole.CASHIER,
|
||||
permissions: Array.isArray(row.permissions) ? row.permissions : undefined,
|
||||
status: row.status,
|
||||
storeIds: row.bindings.map((b) => b.store.id.toString()),
|
||||
stores: row.bindings.map((b) => ({
|
||||
storeId: b.store.id.toString(),
|
||||
name: b.store.name,
|
||||
status: b.store.status,
|
||||
district: b.store.district,
|
||||
address: b.store.address,
|
||||
})),
|
||||
lastLoginAt: row.lastLoginAt?.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private maskPhone(phone: string): string {
|
||||
if (phone.length !== 11) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
|
||||
}
|
||||
}
|
||||
@@ -54,16 +54,20 @@ export class AdminRedeemDebugService {
|
||||
throw new NotFoundException('用户不存在,请检查 ID、用户编号或手机号');
|
||||
}
|
||||
|
||||
private async resolveStoreAccountId(storeId: string): Promise<bigint> {
|
||||
const account = await this.prisma.storeAccount.findFirst({
|
||||
where: { storeId: this.parseStoreId(storeId), status: 'ACTIVE' },
|
||||
private async resolveStoreAccountId(storeId: string): Promise<{ accountId: bigint; storeId: bigint }> {
|
||||
const sid = this.parseStoreId(storeId);
|
||||
const binding = await this.prisma.storeAccountStore.findFirst({
|
||||
where: {
|
||||
storeId: sid,
|
||||
storeAccount: { status: 'ACTIVE', isPrimary: 1 },
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, store: { select: { name: true } } },
|
||||
select: { storeAccountId: true, storeId: true },
|
||||
});
|
||||
if (!account) {
|
||||
if (!binding) {
|
||||
throw new NotFoundException('该门店无可用账户,请先创建门店账户');
|
||||
}
|
||||
return account.id;
|
||||
return { accountId: binding.storeAccountId, storeId: binding.storeId };
|
||||
}
|
||||
|
||||
async createToken(dto: AdminRedeemDebugCreateTokenDto) {
|
||||
@@ -76,32 +80,32 @@ export class AdminRedeemDebugService {
|
||||
}
|
||||
|
||||
async preview(dto: AdminRedeemDebugStoreTokenDto) {
|
||||
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
||||
return this.redeemService.previewRedeem(storeAccountId, dto.token);
|
||||
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
|
||||
return this.redeemService.previewRedeem(accountId, storeId, dto.token);
|
||||
}
|
||||
|
||||
async confirm(dto: AdminRedeemDebugStoreTokenDto) {
|
||||
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
||||
return this.redeemService.confirmRedeem(storeAccountId, { token: dto.token });
|
||||
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
|
||||
return this.redeemService.confirmRedeem(accountId, storeId, { token: dto.token });
|
||||
}
|
||||
|
||||
async sendPhoneLookupSms(dto: AdminRedeemDebugPhoneStoreDto) {
|
||||
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
||||
return this.redeemService.sendPhoneLookupSms(storeAccountId, dto.phone);
|
||||
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
|
||||
return this.redeemService.sendPhoneLookupSms(accountId, storeId, dto.phone);
|
||||
}
|
||||
|
||||
async phoneBalance(dto: AdminRedeemDebugPhoneBalanceDto) {
|
||||
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
||||
return this.redeemService.verifyPhoneAndGetBalance(storeAccountId, dto.phone, dto.code);
|
||||
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
|
||||
return this.redeemService.verifyPhoneAndGetBalance(accountId, storeId, dto.phone, dto.code);
|
||||
}
|
||||
|
||||
async phonePrepare(dto: AdminRedeemDebugPhonePrepareDto) {
|
||||
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
||||
return this.redeemService.preparePhoneRedeem(storeAccountId, dto.sessionId, dto.amount);
|
||||
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
|
||||
return this.redeemService.preparePhoneRedeem(accountId, storeId, dto.sessionId, dto.amount);
|
||||
}
|
||||
|
||||
async phoneConfirm(dto: AdminRedeemDebugPhoneConfirmDto) {
|
||||
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
||||
return this.redeemService.confirmPhoneRedeem(storeAccountId, dto.sessionId, dto.code);
|
||||
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
|
||||
return this.redeemService.confirmPhoneRedeem(accountId, storeId, dto.sessionId, dto.code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,11 +131,13 @@ export class AdminStoreLogsService {
|
||||
if (query.phone) accountWhere.phone = { contains: query.phone };
|
||||
const accounts = await this.prisma.storeAccount.findMany({
|
||||
where: accountWhere,
|
||||
select: { storeId: true },
|
||||
select: {
|
||||
bindings: { select: { storeId: true } },
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
if (accounts.length === 0) return [];
|
||||
const ids = [...new Set(accounts.map((a) => a.storeId))];
|
||||
const ids = [...new Set(accounts.flatMap((a) => a.bindings.map((b) => b.storeId)))];
|
||||
if (storeWhere.name) {
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where: { id: { in: ids }, ...storeWhere },
|
||||
|
||||
@@ -41,14 +41,26 @@ export class AdminStoresService {
|
||||
include: {
|
||||
cityRef: { select: { id: true, name: true, code: true } },
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
account: { select: { id: true, phone: true, name: true, status: true } },
|
||||
bindings: {
|
||||
where: { storeAccount: { isPrimary: 1 } },
|
||||
take: 1,
|
||||
include: {
|
||||
storeAccount: { select: { id: true, phone: true, name: true, status: true } },
|
||||
},
|
||||
},
|
||||
coverResource: { select: { id: true, url: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.store.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((s) => mapStoreCompat(s)),
|
||||
items: items.map((s) =>
|
||||
mapStoreCompat({
|
||||
...s,
|
||||
account: s.bindings[0]?.storeAccount ?? null,
|
||||
bindings: undefined,
|
||||
}),
|
||||
),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -62,7 +74,11 @@ export class AdminStoresService {
|
||||
cityRef: true,
|
||||
partnerAccount: true,
|
||||
category: true,
|
||||
account: true,
|
||||
bindings: {
|
||||
where: { storeAccount: { isPrimary: 1 } },
|
||||
take: 1,
|
||||
include: { storeAccount: true },
|
||||
},
|
||||
coverResource: true,
|
||||
_count: { select: { redeemRecords: true, ratings: true } },
|
||||
},
|
||||
@@ -81,6 +97,8 @@ export class AdminStoresService {
|
||||
]);
|
||||
return serializeBigInt(mapStoreCompat({
|
||||
...store,
|
||||
account: store.bindings[0]?.storeAccount ?? null,
|
||||
bindings: undefined,
|
||||
media,
|
||||
audits,
|
||||
redeemCount: store._count.redeemRecords,
|
||||
@@ -165,7 +183,12 @@ export class AdminStoresService {
|
||||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
});
|
||||
if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
|
||||
if (existingAccount && existingAccount.isPrimary !== 1) {
|
||||
throw new BadRequestException('该手机号已是门店子账号');
|
||||
}
|
||||
if (existingAccount && existingAccount.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('该手机号对应门店账号已停用');
|
||||
}
|
||||
|
||||
const partnerAccountId = BigInt(dto.partnerAccountId);
|
||||
const partnerAccount = await this.prisma.partnerAccount.findUnique({
|
||||
@@ -191,9 +214,6 @@ export class AdminStoresService {
|
||||
district: dto.district ?? '',
|
||||
address: dto.address,
|
||||
intro: dto.intro ?? null,
|
||||
bankAccountName: dto.bankAccountName ?? null,
|
||||
bankAccountNo: dto.bankAccountNo ?? null,
|
||||
bankBranch: dto.bankBranch ?? null,
|
||||
openTime: '10:00',
|
||||
closeTime: '22:00',
|
||||
status: 'OPEN',
|
||||
@@ -258,13 +278,37 @@ export class AdminStoresService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
phone: normalizedPhone,
|
||||
name: dto.accountName ?? dto.name,
|
||||
},
|
||||
});
|
||||
const bankAccountName = dto.bankAccountName ?? null;
|
||||
const bankAccountNo = dto.bankAccountNo ?? null;
|
||||
const bankBranch = dto.bankBranch ?? null;
|
||||
|
||||
if (existingAccount) {
|
||||
await this.prisma.storeAccountStore.create({
|
||||
data: { storeAccountId: existingAccount.id, storeId: store.id },
|
||||
});
|
||||
if (bankAccountName || bankAccountNo || bankBranch) {
|
||||
await this.prisma.storeAccount.update({
|
||||
where: { id: existingAccount.id },
|
||||
data: {
|
||||
...(bankAccountName != null ? { bankAccountName } : {}),
|
||||
...(bankAccountNo != null ? { bankAccountNo } : {}),
|
||||
...(bankBranch != null ? { bankBranch } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
phone: normalizedPhone,
|
||||
name: dto.accountName ?? dto.name,
|
||||
isPrimary: 1,
|
||||
bankAccountName,
|
||||
bankAccountNo,
|
||||
bankBranch,
|
||||
bindings: { create: [{ storeId: store.id }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.detailStore(store.id);
|
||||
}
|
||||
@@ -272,12 +316,37 @@ export class AdminStoresService {
|
||||
async createStoreAccount(dto: CreateStoreAccountDto) {
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id: BigInt(dto.storeId) },
|
||||
include: { account: true },
|
||||
include: { bindings: true },
|
||||
});
|
||||
if (!store) throw new BadRequestException('门店不存在');
|
||||
if (store.account) throw new BadRequestException('门店已有账户');
|
||||
const primaryBound = store.bindings.length > 0
|
||||
? await this.prisma.storeAccount.findFirst({
|
||||
where: {
|
||||
isPrimary: 1,
|
||||
bindings: { some: { storeId: store.id } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (primaryBound) throw new BadRequestException('门店已有主账号绑定');
|
||||
|
||||
const existing = await this.prisma.storeAccount.findUnique({ where: { phone: dto.phone } });
|
||||
if (existing) {
|
||||
if (existing.isPrimary !== 1) {
|
||||
throw new BadRequestException('该手机号已是门店子账号');
|
||||
}
|
||||
await this.prisma.storeAccountStore.create({
|
||||
data: { storeAccountId: existing.id, storeId: store.id },
|
||||
});
|
||||
return serializeBigInt(existing);
|
||||
}
|
||||
|
||||
const account = await this.prisma.storeAccount.create({
|
||||
data: { storeId: store.id, phone: dto.phone, name: dto.name },
|
||||
data: {
|
||||
phone: dto.phone,
|
||||
name: dto.name,
|
||||
isPrimary: 1,
|
||||
bindings: { create: [{ storeId: store.id }] },
|
||||
},
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
@@ -345,9 +414,11 @@ export class AdminStoresService {
|
||||
async listStoreAccounts(query: AdminStoreAccountsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StoreAccountWhereInput = {};
|
||||
const where: Prisma.StoreAccountWhereInput = { isPrimary: 1 };
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
if (query.storeId) {
|
||||
where.bindings = { some: { storeId: BigInt(query.storeId) } };
|
||||
}
|
||||
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
@@ -357,21 +428,53 @@ export class AdminStoresService {
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
store: { select: { id: true, name: true, status: true, cityName: true } },
|
||||
bindings: {
|
||||
include: {
|
||||
store: { select: { id: true, name: true, status: true, cityName: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { childAccounts: true, bindings: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.storeAccount.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
const mapped = items.map((row) => ({
|
||||
...row,
|
||||
storeCount: row._count.bindings,
|
||||
staffCount: row._count.childAccounts,
|
||||
stores: row.bindings.map((b) => b.store),
|
||||
store: row.bindings[0]?.store ?? null,
|
||||
}));
|
||||
return serializeBigInt({ items: mapped, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detailStoreAccount(id: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id },
|
||||
include: { store: { include: { cityRef: true, partnerAccount: true } } },
|
||||
include: {
|
||||
bindings: {
|
||||
include: {
|
||||
store: { include: { cityRef: true, partnerAccount: true } },
|
||||
},
|
||||
},
|
||||
childAccounts: {
|
||||
include: {
|
||||
bindings: {
|
||||
include: {
|
||||
store: { select: { id: true, name: true, status: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!account) throw new NotFoundException('门店账号不存在');
|
||||
return serializeBigInt(account);
|
||||
return serializeBigInt({
|
||||
...account,
|
||||
stores: account.bindings.map((b) => b.store),
|
||||
store: account.bindings[0]?.store ?? null,
|
||||
staff: account.childAccounts,
|
||||
});
|
||||
}
|
||||
|
||||
async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) {
|
||||
|
||||
@@ -185,11 +185,17 @@ export class AdminWechatBindingsService {
|
||||
|
||||
const accounts = await this.prisma.storeAccount.findMany({
|
||||
where,
|
||||
include: { store: { select: { id: true, name: true } } },
|
||||
include: {
|
||||
bindings: {
|
||||
take: 1,
|
||||
include: { store: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const a of accounts) {
|
||||
if (!a.wxOpenId) continue;
|
||||
const firstStore = a.bindings[0]?.store;
|
||||
rows.push({
|
||||
actorType: 'STORE',
|
||||
actorId: a.id,
|
||||
@@ -197,8 +203,8 @@ export class AdminWechatBindingsService {
|
||||
name: a.name,
|
||||
wxOpenId: a.wxOpenId,
|
||||
wxUnionId: a.wxUnionId,
|
||||
refId: a.storeId,
|
||||
refLabel: a.store.name,
|
||||
refId: firstStore?.id,
|
||||
refLabel: firstStore?.name ?? a.name,
|
||||
lastLoginAt: a.lastLoginAt,
|
||||
status: a.status,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { RedeemService } from './redeem.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import {
|
||||
RedeemPhoneBalanceDto,
|
||||
@@ -37,18 +38,18 @@ export class UserRedeemController {
|
||||
}
|
||||
|
||||
@Controller('shop/redeem')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||
export class ShopRedeemController {
|
||||
constructor(private readonly redeemService: RedeemService) {}
|
||||
|
||||
@Post('preview')
|
||||
preview(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
|
||||
return this.redeemService.previewRedeem(user.actorId, body.token);
|
||||
return this.redeemService.previewRedeem(user.actorId, user.storeId!, body.token);
|
||||
}
|
||||
|
||||
@Post('confirm')
|
||||
confirm(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
|
||||
return this.redeemService.confirmRedeem(user.actorId, body);
|
||||
return this.redeemService.confirmRedeem(user.actorId, user.storeId!, body);
|
||||
}
|
||||
|
||||
@Post('failures')
|
||||
@@ -56,7 +57,7 @@ export class ShopRedeemController {
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() body: RedeemFailureReportDto,
|
||||
) {
|
||||
return this.redeemService.reportNetworkFailure(user.actorId, body);
|
||||
return this.redeemService.reportNetworkFailure(user.actorId, user.storeId!, body);
|
||||
}
|
||||
|
||||
@Post('pending')
|
||||
@@ -64,7 +65,7 @@ export class ShopRedeemController {
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() body: RedeemPendingSubmitDto,
|
||||
) {
|
||||
return this.redeemService.submitPendingRedeem(user.actorId, body);
|
||||
return this.redeemService.submitPendingRedeem(user.actorId, user.storeId!, body);
|
||||
}
|
||||
|
||||
@Get('records')
|
||||
@@ -73,26 +74,46 @@ export class ShopRedeemController {
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.redeemService.listShopRecords(user.actorId, Number(page), Number(pageSize));
|
||||
return this.redeemService.listShopRecords(
|
||||
user.actorId,
|
||||
user.storeId!,
|
||||
Number(page),
|
||||
Number(pageSize),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('phone/send-lookup-sms')
|
||||
sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) {
|
||||
return this.redeemService.sendPhoneLookupSms(user.actorId, body.phone);
|
||||
return this.redeemService.sendPhoneLookupSms(user.actorId, user.storeId!, body.phone);
|
||||
}
|
||||
|
||||
@Post('phone/balance')
|
||||
phoneBalance(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneBalanceDto) {
|
||||
return this.redeemService.verifyPhoneAndGetBalance(user.actorId, body.phone, body.code);
|
||||
return this.redeemService.verifyPhoneAndGetBalance(
|
||||
user.actorId,
|
||||
user.storeId!,
|
||||
body.phone,
|
||||
body.code,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('phone/prepare')
|
||||
phonePrepare(@CurrentUser() user: AuthUser, @Body() body: RedeemPhonePrepareDto) {
|
||||
return this.redeemService.preparePhoneRedeem(user.actorId, body.sessionId, body.amount);
|
||||
return this.redeemService.preparePhoneRedeem(
|
||||
user.actorId,
|
||||
user.storeId!,
|
||||
body.sessionId,
|
||||
body.amount,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('phone/confirm')
|
||||
phoneConfirm(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneConfirmDto) {
|
||||
return this.redeemService.confirmPhoneRedeem(user.actorId, body.sessionId, body.code);
|
||||
return this.redeemService.confirmPhoneRedeem(
|
||||
user.actorId,
|
||||
user.storeId!,
|
||||
body.sessionId,
|
||||
body.code,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,15 +89,28 @@ export class RedeemService {
|
||||
return `redeem:phone-session:${sessionId}`;
|
||||
}
|
||||
|
||||
private async loadOpenStoreAccount(storeAccountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
include: { store: true },
|
||||
private async loadOpenStoreAccount(storeAccountId: bigint, storeId: bigint) {
|
||||
const binding = await this.prisma.storeAccountStore.findUnique({
|
||||
where: {
|
||||
storeAccountId_storeId: { storeAccountId, storeId },
|
||||
},
|
||||
include: {
|
||||
storeAccount: true,
|
||||
store: true,
|
||||
},
|
||||
});
|
||||
if (account.store.status !== 'OPEN') {
|
||||
if (!binding) throw new BadRequestException('无权访问该门店');
|
||||
if (binding.storeAccount.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('门店账号已停用');
|
||||
}
|
||||
if (binding.store.status !== 'OPEN') {
|
||||
throw new BadRequestException('门店未营业');
|
||||
}
|
||||
return account;
|
||||
return {
|
||||
...binding.storeAccount,
|
||||
storeId: binding.store.id,
|
||||
store: binding.store,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveUserByPhone(phone: string) {
|
||||
@@ -228,8 +241,8 @@ export class RedeemService {
|
||||
return record;
|
||||
}
|
||||
|
||||
async sendPhoneLookupSms(storeAccountId: bigint, phone: string) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
||||
async sendPhoneLookupSms(storeAccountId: bigint, storeId: bigint, phone: string) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||
const normalizedPhone = this.normalizeMobilePhone(phone);
|
||||
await this.resolveUserByPhone(normalizedPhone);
|
||||
await this.authService.sendSms(normalizedPhone, SmsScene.REDEEM_PHONE_LOOKUP, {
|
||||
@@ -243,8 +256,8 @@ export class RedeemService {
|
||||
return { ok: true, maskedPhone: this.maskPhoneForStore(normalizedPhone) };
|
||||
}
|
||||
|
||||
async verifyPhoneAndGetBalance(storeAccountId: bigint, phone: string, code: string) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
||||
async verifyPhoneAndGetBalance(storeAccountId: bigint, storeId: bigint, phone: string, code: string) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||
const normalizedPhone = this.normalizeMobilePhone(phone);
|
||||
const user = await this.resolveUserByPhone(normalizedPhone);
|
||||
await this.authService.verifySmsCode(normalizedPhone, code, SmsScene.REDEEM_PHONE_LOOKUP);
|
||||
@@ -298,8 +311,8 @@ export class RedeemService {
|
||||
return session;
|
||||
}
|
||||
|
||||
async preparePhoneRedeem(storeAccountId: bigint, sessionId: string, amount: number) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
||||
async preparePhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, amount: number) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||
const session = await this.loadPhoneSession(sessionId, storeAccountId);
|
||||
const userId = BigInt(session.userId);
|
||||
const { allocations } = await this.computeDirectAllocations(userId, amount);
|
||||
@@ -337,8 +350,8 @@ export class RedeemService {
|
||||
};
|
||||
}
|
||||
|
||||
async confirmPhoneRedeem(storeAccountId: bigint, sessionId: string, code: string) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
||||
async confirmPhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, code: string) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||
const session = await this.loadPhoneSession(sessionId, storeAccountId);
|
||||
if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) {
|
||||
throw new BadRequestException('请先选择核销金额并发送确认验证码');
|
||||
@@ -462,8 +475,8 @@ export class RedeemService {
|
||||
return { status: 'EXPIRED' as const };
|
||||
}
|
||||
|
||||
async previewRedeem(storeAccountId: bigint, token: string) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
||||
async previewRedeem(storeAccountId: bigint, storeId: bigint, token: string) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||
|
||||
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
|
||||
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
||||
@@ -516,8 +529,8 @@ export class RedeemService {
|
||||
});
|
||||
}
|
||||
|
||||
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
||||
async confirmRedeem(storeAccountId: bigint, storeId: bigint, body: { token: string }) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||
const token = body.token?.trim();
|
||||
if (!token) throw new BadRequestException('请提供核销码');
|
||||
|
||||
@@ -594,6 +607,7 @@ export class RedeemService {
|
||||
|
||||
async reportNetworkFailure(
|
||||
storeAccountId: bigint,
|
||||
storeId: bigint,
|
||||
body: {
|
||||
token: string;
|
||||
errorClass: 'NETWORK' | 'BUSINESS';
|
||||
@@ -601,9 +615,7 @@ export class RedeemService {
|
||||
step: 'preview' | 'confirm';
|
||||
},
|
||||
) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
});
|
||||
await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||
const token = body.token.trim();
|
||||
if (!token) throw new BadRequestException('请提供核销码');
|
||||
|
||||
@@ -618,7 +630,7 @@ export class RedeemService {
|
||||
const thresholdReached = failCount >= REDEEM_WEAKNET_FAIL_THRESHOLD;
|
||||
|
||||
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||||
storeId: account.storeId,
|
||||
storeId,
|
||||
eventName: 'store_redeem_confirm_fail',
|
||||
extraJson: {
|
||||
token,
|
||||
@@ -632,7 +644,7 @@ export class RedeemService {
|
||||
|
||||
if (thresholdReached && body.errorClass === 'NETWORK') {
|
||||
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||||
storeId: account.storeId,
|
||||
storeId,
|
||||
eventName: 'store_redeem_weaknet_threshold',
|
||||
extraJson: {
|
||||
token,
|
||||
@@ -670,9 +682,10 @@ export class RedeemService {
|
||||
|
||||
async submitPendingRedeem(
|
||||
storeAccountId: bigint,
|
||||
storeId: bigint,
|
||||
body: { token: string; photoResourceId: string; failCount?: number; remark?: string },
|
||||
) {
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
||||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||
const token = body.token.trim();
|
||||
if (!token) throw new BadRequestException('请提供核销码');
|
||||
|
||||
@@ -831,13 +844,7 @@ export class RedeemService {
|
||||
throw new BadRequestException('待处理单状态不可补核销');
|
||||
}
|
||||
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: pending.storeAccountId },
|
||||
include: { store: true },
|
||||
});
|
||||
if (account.store.status !== 'OPEN') {
|
||||
throw new BadRequestException('门店未营业,无法补核销');
|
||||
}
|
||||
const account = await this.loadOpenStoreAccount(pending.storeAccountId, pending.storeId);
|
||||
|
||||
const allocationsRaw = pending.allocationsJson as Array<{ couponId: string; amount: number }>;
|
||||
const normalizedAllocations = allocationsRaw.map((item) => ({
|
||||
@@ -941,42 +948,42 @@ export class RedeemService {
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async listShopRecords(storeAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
async listShopRecords(storeAccountId: bigint, storeId: bigint, page = 1, pageSize = 20) {
|
||||
await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||
});
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.redeemRecord.findMany({
|
||||
where: { storeId: account.storeId },
|
||||
where: { storeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { payout: true },
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { storeId: account.storeId } }),
|
||||
this.prisma.redeemRecord.count({ where: { storeId } }),
|
||||
]);
|
||||
return { list: serializeBigInt(list), total, page, pageSize };
|
||||
}
|
||||
|
||||
async getShopDashboard(storeAccountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
async getShopDashboard(storeAccountId: bigint, storeId: bigint) {
|
||||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||
include: { store: true },
|
||||
});
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const records = await this.prisma.redeemRecord.findMany({
|
||||
where: { storeId: account.storeId, createdAt: { gte: start } },
|
||||
where: { storeId, createdAt: { gte: start } },
|
||||
});
|
||||
const todayCount = records.length;
|
||||
const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0);
|
||||
const recent = await this.prisma.redeemRecord.findMany({
|
||||
where: { storeId: account.storeId },
|
||||
where: { storeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 3,
|
||||
});
|
||||
return serializeBigInt({
|
||||
store: account.store,
|
||||
store: binding.store,
|
||||
todayCount,
|
||||
todayAmount,
|
||||
recentRecords: recent,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
@@ -20,7 +21,7 @@ export class SettlementController {
|
||||
}
|
||||
|
||||
@Controller('shop/payouts')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||
export class ShopPayoutController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@@ -30,7 +31,12 @@ export class ShopPayoutController {
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.settlementService.listShopPayouts(user.actorId, Number(page), Number(pageSize));
|
||||
return this.settlementService.listShopPayouts(
|
||||
user.actorId,
|
||||
user.storeId!,
|
||||
Number(page),
|
||||
Number(pageSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,11 +54,11 @@ export class SettlementService {
|
||||
return serializeBigInt(payout);
|
||||
}
|
||||
|
||||
async listShopPayouts(storeAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
async listShopPayouts(storeAccountId: bigint, storeId: bigint, page = 1, pageSize = 20) {
|
||||
await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||
});
|
||||
const where = { storeId: account.storeId };
|
||||
const where = { storeId };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storePayout.findMany({
|
||||
where,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StoreService } from './store.service';
|
||||
import { RedeemService } from '../redeem/redeem.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('stores')
|
||||
@@ -105,28 +106,28 @@ export class PartnerReportController {
|
||||
}
|
||||
|
||||
@Controller('shop/store')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||
export class ShopStoreController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
info(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.getShopStore(user.actorId);
|
||||
return this.storeService.getShopStore(user.actorId, user.storeId!);
|
||||
}
|
||||
|
||||
@Put('status')
|
||||
status(@CurrentUser() user: AuthUser, @Body() body: { status: 'OPEN' | 'PAUSED' }) {
|
||||
return this.storeService.updateShopStatus(user.actorId, body.status);
|
||||
return this.storeService.updateShopStatus(user.actorId, user.storeId!, body.status);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/dashboard')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||
export class ShopDashboardController {
|
||||
constructor(private readonly redeemService: RedeemService) {}
|
||||
|
||||
@Get()
|
||||
async dashboard(@CurrentUser() user: AuthUser) {
|
||||
return this.redeemService.getShopDashboard(user.actorId);
|
||||
return this.redeemService.getShopDashboard(user.actorId, user.storeId!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,17 +116,34 @@ export class StoreService {
|
||||
}
|
||||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
include: { _count: { select: { bindings: true } } },
|
||||
});
|
||||
if (existingAccount) {
|
||||
return { available: false, message: '该手机号已绑定门店' };
|
||||
if (!existingAccount) {
|
||||
return { available: true, existingStoreCount: 0, needConfirm: false };
|
||||
}
|
||||
return { available: true };
|
||||
if (existingAccount.isPrimary !== 1) {
|
||||
return { available: false, message: '该手机号已是门店子账号,不可作为负责人' };
|
||||
}
|
||||
if (existingAccount.status !== 'ACTIVE') {
|
||||
return { available: false, message: '该手机号对应门店账号已停用' };
|
||||
}
|
||||
const existingStoreCount = existingAccount._count.bindings;
|
||||
return {
|
||||
available: true,
|
||||
existingStoreCount,
|
||||
needConfirm: existingStoreCount > 0,
|
||||
message:
|
||||
existingStoreCount > 0
|
||||
? `该手机号已是门店主账号(已绑 ${existingStoreCount} 家店),确认后将追加绑定新店`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
||||
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||
const normalizedPhone = String(body.phone).trim();
|
||||
await this.assertStorePhoneAvailable(normalizedPhone);
|
||||
const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true';
|
||||
await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting);
|
||||
|
||||
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||
@@ -139,6 +156,10 @@ export class StoreService {
|
||||
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
|
||||
if (!contractUrl) throw new BadRequestException('请上传签约合同');
|
||||
|
||||
const bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null;
|
||||
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
|
||||
const bankBranch = body.bankBranch ? String(body.bankBranch) : null;
|
||||
|
||||
const store = await this.prisma.store.create({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
@@ -151,9 +172,6 @@ export class StoreService {
|
||||
district: String(body.district ?? ''),
|
||||
address: String(body.address),
|
||||
intro: body.intro ? String(body.intro) : null,
|
||||
bankAccountName: body.bankAccountName ? String(body.bankAccountName) : null,
|
||||
bankAccountNo: body.bankAccountNo ? String(body.bankAccountNo) : null,
|
||||
bankBranch: body.bankBranch ? String(body.bankBranch) : null,
|
||||
openTime: body.openTime ? String(body.openTime) : '10:00',
|
||||
closeTime: body.closeTime ? String(body.closeTime) : '22:00',
|
||||
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
|
||||
@@ -219,14 +237,38 @@ export class StoreService {
|
||||
extraJson: body as never,
|
||||
},
|
||||
});
|
||||
void audit;
|
||||
|
||||
await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
phone: normalizedPhone,
|
||||
name: String(body.name),
|
||||
},
|
||||
const existingPrimary = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
});
|
||||
if (existingPrimary && existingPrimary.isPrimary === 1) {
|
||||
await this.prisma.storeAccountStore.create({
|
||||
data: { storeAccountId: existingPrimary.id, storeId: store.id },
|
||||
});
|
||||
if (bankAccountName || bankAccountNo || bankBranch) {
|
||||
await this.prisma.storeAccount.update({
|
||||
where: { id: existingPrimary.id },
|
||||
data: {
|
||||
...(bankAccountName != null ? { bankAccountName } : {}),
|
||||
...(bankAccountNo != null ? { bankAccountNo } : {}),
|
||||
...(bankBranch != null ? { bankBranch } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
phone: normalizedPhone,
|
||||
name: String(body.name),
|
||||
isPrimary: 1,
|
||||
bankAccountName,
|
||||
bankAccountNo,
|
||||
bankBranch,
|
||||
bindings: { create: [{ storeId: store.id }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerAccountId: primaryId,
|
||||
@@ -321,26 +363,26 @@ export class StoreService {
|
||||
return this.partnerGetStore(partnerAccountId, storeId);
|
||||
}
|
||||
|
||||
async getShopStore(storeAccountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
async getShopStore(storeAccountId: bigint, storeId: bigint) {
|
||||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||
include: { store: { include: { category: true, coverResource: true } } },
|
||||
});
|
||||
return serializeBigInt(mapStoreCompat(account.store));
|
||||
return serializeBigInt(mapStoreCompat(binding.store));
|
||||
}
|
||||
|
||||
async updateShopStatus(storeAccountId: bigint, status: 'OPEN' | 'PAUSED') {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
async updateShopStatus(storeAccountId: bigint, storeId: bigint, status: 'OPEN' | 'PAUSED') {
|
||||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||
include: { store: true },
|
||||
});
|
||||
const previousStatus = account.store.status;
|
||||
const previousStatus = binding.store.status;
|
||||
const store = await this.prisma.store.update({
|
||||
where: { id: account.storeId },
|
||||
where: { id: storeId },
|
||||
data: { status },
|
||||
});
|
||||
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
|
||||
storeId: account.storeId,
|
||||
storeId,
|
||||
eventName: 'store_status_change',
|
||||
extraJson: {
|
||||
status,
|
||||
@@ -721,10 +763,13 @@ export class StoreService {
|
||||
}
|
||||
}
|
||||
|
||||
private async assertStorePhoneAvailable(phone: string) {
|
||||
private async assertStorePhoneAvailable(phone: string, confirmBindExisting = false) {
|
||||
const result = await this.partnerCheckStorePhone(phone);
|
||||
if (!result.available) {
|
||||
throw new BadRequestException(result.message ?? '该手机号已绑定门店');
|
||||
throw new BadRequestException(result.message ?? '该手机号不可用');
|
||||
}
|
||||
if (result.needConfirm && !confirmBindExisting) {
|
||||
throw new BadRequestException(result.message ?? '该手机号已绑定门店,请确认后重试');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user