门店账户多账号
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 { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string; phone: string; name: string; status: string; createdAt: string;
|
id: string;
|
||||||
store?: { id: string; name: string; status: string; cityName: 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 };
|
type StoreOption = { id: string; name: string };
|
||||||
@@ -41,17 +54,53 @@ export default function StoreAccountsPage() {
|
|||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
{ 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: '绑定门店',
|
||||||
{ title: '账号状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
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: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作', width: 80,
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={async () => {
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
setDetail(await request(`/admin/store-accounts/${row.id}`));
|
setDetail(await request(`/admin/store-accounts/${row.id}`));
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}>详情</Button>
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -61,48 +110,141 @@ export default function StoreAccountsPage() {
|
|||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Space direction="vertical" size={0}>
|
<Space direction="vertical" size={0}>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
||||||
<Typography.Text type="secondary">新建门店时自动开通主账号;「新建账户」仅用于补录历史无账号门店</Typography.Text>
|
<Typography.Text type="secondary">
|
||||||
|
主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店
|
||||||
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新建账户</Button>
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
void loadStores();
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建账户
|
||||||
|
</Button>
|
||||||
</Space>
|
</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="phone" label="手机"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="status" label="状态">
|
<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>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
<Table
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
rowKey="id"
|
||||||
<Drawer title="门店账户" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
className="admin-table-nowrap"
|
||||||
extra={detail && (
|
loading={loading}
|
||||||
<Select defaultValue={detail.status} style={{ width: 100 }}
|
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 }))}
|
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
onChange={async (status) => {
|
onChange={async (status) => {
|
||||||
await request(`/admin/store-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify({ status }) });
|
await request(`/admin/store-accounts/${detail.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
message.success('已更新');
|
message.success('已更新');
|
||||||
void reload();
|
void reload();
|
||||||
}} />
|
}}
|
||||||
)}>
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
|
<>
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
|
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
|
||||||
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
|
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
|
||||||
<Descriptions.Item label="门店">{detail.store?.name}</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>
|
</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>
|
</Drawer>
|
||||||
<Modal title="新建门店账户" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
<Modal
|
||||||
|
title="新建门店账户"
|
||||||
|
open={createOpen}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
const v = await createForm.validateFields();
|
const v = await createForm.validateFields();
|
||||||
await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) });
|
await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||||
message.success('已创建');
|
message.success('已创建');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
void reload();
|
void reload();
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Form form={createForm} layout="vertical">
|
<Form form={createForm} layout="vertical">
|
||||||
<Form.Item name="storeId" label="门店" rules={[{ required: true }]}>
|
<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>
|
||||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="phone" 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 });
|
setFieldErrors({ phone: phoneMsg });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (phoneCheck.needConfirm) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
phoneCheck.message ??
|
||||||
|
`该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`,
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||||
@@ -381,6 +391,8 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
|
|
||||||
|
let confirmBindExisting = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
|
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) {
|
} catch (e) {
|
||||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||||
return;
|
return;
|
||||||
@@ -440,6 +464,8 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
bankBranch: form.bankBranch.trim(),
|
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 AuthGate from './components/AuthGate';
|
||||||
import TabLayout from './layouts/TabLayout';
|
import TabLayout from './layouts/TabLayout';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import SelectStorePage from './pages/SelectStorePage';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
import RedeemConfirmPage from './pages/RedeemConfirmPage';
|
import RedeemConfirmPage from './pages/RedeemConfirmPage';
|
||||||
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
||||||
@@ -9,12 +10,15 @@ import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
|||||||
import RecordsPage from './pages/RecordsPage';
|
import RecordsPage from './pages/RecordsPage';
|
||||||
import StatusPage from './pages/StatusPage';
|
import StatusPage from './pages/StatusPage';
|
||||||
import MinePage from './pages/MinePage';
|
import MinePage from './pages/MinePage';
|
||||||
|
import StaffPage from './pages/StaffPage';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AuthGate>
|
<AuthGate>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/select-store" element={<SelectStorePage />} />
|
||||||
|
<Route path="/staff" element={<StaffPage />} />
|
||||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
|||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
|
||||||
const PUBLIC_PATHS = new Set(['/login']);
|
const PUBLIC_PATHS = new Set(['/login']);
|
||||||
|
const SELECT_STORE_PATH = '/select-store';
|
||||||
|
|
||||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
const { ready, authenticated } = useStoreSession();
|
const { ready, authenticated, needsSelectStore } = useStoreSession();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
@@ -17,7 +18,11 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authenticated && location.pathname === '/login') {
|
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)) {
|
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type StoreSessionContextValue = {
|
type StoreSessionContextValue = {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
|
needsSelectStore: boolean;
|
||||||
store: StoreSessionStore | null;
|
store: StoreSessionStore | null;
|
||||||
applySession: (session: ShopSessionPayload) => void;
|
applySession: (session: ShopSessionPayload) => void;
|
||||||
resetSession: () => void;
|
resetSession: () => void;
|
||||||
@@ -28,17 +29,29 @@ const StoreSessionContext = createContext<StoreSessionContextValue | null>(null)
|
|||||||
export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [authenticated, setAuthenticated] = useState(false);
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
|
const [needsSelectStore, setNeedsSelectStore] = useState(false);
|
||||||
const [store, setStore] = useState<StoreSessionStore | null>(null);
|
const [store, setStore] = useState<StoreSessionStore | null>(null);
|
||||||
|
|
||||||
const applySession = useCallback((session: ShopSessionPayload) => {
|
const applySession = useCallback((session: ShopSessionPayload) => {
|
||||||
saveAuth(session);
|
saveAuth(session);
|
||||||
setAuthenticated(true);
|
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(() => {
|
const resetSession = useCallback(() => {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
|
setNeedsSelectStore(false);
|
||||||
setStore(null);
|
setStore(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -50,6 +63,7 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setAuthenticated(result.authenticated);
|
setAuthenticated(result.authenticated);
|
||||||
setStore(result.store);
|
setStore(result.store);
|
||||||
|
setNeedsSelectStore(result.needsSelectStore);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) resetSession();
|
if (!cancelled) resetSession();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -62,8 +76,8 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [resetSession]);
|
}, [resetSession]);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ ready, authenticated, store, applySession, resetSession }),
|
() => ({ ready, authenticated, needsSelectStore, store, applySession, resetSession }),
|
||||||
[ready, authenticated, store, applySession, resetSession],
|
[ready, authenticated, needsSelectStore, store, applySession, resetSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <StoreSessionContext.Provider value={value}>{children}</StoreSessionContext.Provider>;
|
return <StoreSessionContext.Provider value={value}>{children}</StoreSessionContext.Provider>;
|
||||||
|
|||||||
+100
-17
@@ -1,12 +1,22 @@
|
|||||||
export const apiBase = '/api/v1';
|
export const apiBase = '/api/v1';
|
||||||
const CLIENT_APP = 'SHOP_H5';
|
const CLIENT_APP = 'SHOP_H5';
|
||||||
|
|
||||||
|
export type ShopStoreOption = {
|
||||||
|
storeId: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
district?: string;
|
||||||
|
address?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type StoreSessionStore = {
|
export type StoreSessionStore = {
|
||||||
id: string;
|
id: string;
|
||||||
storeId: string;
|
storeId: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
storeName: string;
|
storeName: string;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
stores?: ShopStoreOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StoreProfile = {
|
export type StoreProfile = {
|
||||||
@@ -15,13 +25,25 @@ export type StoreProfile = {
|
|||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
status?: 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 = {
|
export type ShopSessionPayload = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
store?: StoreSessionStore;
|
store?: StoreSessionStore;
|
||||||
|
stores?: ShopStoreOption[];
|
||||||
|
account?: StoreProfile['account'];
|
||||||
|
selectedStoreId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACCESS_TOKEN = 'accessToken';
|
const ACCESS_TOKEN = 'accessToken';
|
||||||
@@ -73,8 +95,27 @@ export function saveAuth(data: ShopSessionPayload) {
|
|||||||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||||
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||||
if (data.store) {
|
if (data.store) {
|
||||||
localStorage.setItem(STORE_PROFILE, JSON.stringify(data.store));
|
const profile: StoreSessionStore = {
|
||||||
localStorage.setItem(LAST_PHONE, data.store.phone);
|
...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 {
|
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 {
|
return {
|
||||||
id: me.id,
|
id: me.account?.id ?? me.id,
|
||||||
storeId: me.storeId,
|
storeId: selected?.storeId ?? me.storeId ?? '',
|
||||||
name: me.name,
|
name: me.account?.name ?? me.name,
|
||||||
phone: me.phone,
|
phone: me.account?.phone ?? me.phone,
|
||||||
storeName: me.store?.name ?? me.name,
|
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>(
|
async function rawRequest<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
@@ -190,13 +261,17 @@ export async function request<T>(clientApp: string, path: string, options: Reque
|
|||||||
return requestWithAuthRetry<T>(path, options);
|
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()) {
|
if (!isLoggedIn()) {
|
||||||
return { authenticated: false, store: null };
|
return { authenticated: false, store: null, needsSelectStore: false };
|
||||||
}
|
}
|
||||||
if (isShopSessionExpired()) {
|
if (isShopSessionExpired()) {
|
||||||
clearAuth({ keepProfile: true });
|
clearAuth({ keepProfile: true });
|
||||||
return { authenticated: false, store: getStoreProfile() };
|
return { authenticated: false, store: getStoreProfile(), needsSelectStore: false };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const me = await rawRequest<StoreProfile>('/shop/auth/me');
|
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) ?? '',
|
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||||
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||||
store,
|
store,
|
||||||
|
stores: me.stores,
|
||||||
|
account: me.account,
|
||||||
});
|
});
|
||||||
touchShopSession();
|
touchShopSession();
|
||||||
return { authenticated: true, store };
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
store,
|
||||||
|
needsSelectStore: needsStoreSelection({ store, stores: me.stores }),
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as Error & { status?: number };
|
const err = e as Error & { status?: number };
|
||||||
if (err.status === 401) {
|
if (err.status === 401) {
|
||||||
const refreshed = await refreshSession();
|
const refreshed = await refreshSession();
|
||||||
if (refreshed?.store) {
|
if (refreshed) {
|
||||||
return { authenticated: true, store: refreshed.store };
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
store: refreshed.store ?? getStoreProfile(),
|
||||||
|
needsSelectStore: needsStoreSelection(refreshed),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
clearAuth({ keepProfile: true });
|
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;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,17 +36,26 @@ export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null):
|
|||||||
|
|
||||||
export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPayload | null {
|
export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPayload | null {
|
||||||
if (!result.accessToken || !result.refreshToken) return 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 {
|
return {
|
||||||
accessToken: result.accessToken,
|
accessToken: result.accessToken,
|
||||||
refreshToken: result.refreshToken,
|
refreshToken: result.refreshToken,
|
||||||
|
stores,
|
||||||
|
account,
|
||||||
|
selectedStoreId: (result as { selectedStoreId?: string }).selectedStoreId,
|
||||||
store: store
|
store: store
|
||||||
? {
|
? {
|
||||||
id: String(store.id ?? ''),
|
id: String(store.id ?? account?.id ?? ''),
|
||||||
storeId: String(store.storeId ?? ''),
|
storeId: String(store.storeId ?? ''),
|
||||||
name: String(store.name ?? ''),
|
name: String(store.name ?? account?.name ?? ''),
|
||||||
phone: String(store.phone ?? ''),
|
phone: String(store.phone ?? account?.phone ?? ''),
|
||||||
storeName: String(store.storeName ?? store.name ?? ''),
|
storeName: String(store.storeName ?? store.name ?? ''),
|
||||||
|
isPrimary: account?.isPrimary,
|
||||||
|
stores,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
|||||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
|
import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
|
||||||
|
import { routeAfterShopLogin } from './SelectStorePage';
|
||||||
import {
|
import {
|
||||||
bindShopWechatAfterSmsLogin,
|
bindShopWechatAfterSmsLogin,
|
||||||
fetchClientConfig,
|
fetchClientConfig,
|
||||||
@@ -58,7 +59,7 @@ export default function LoginPage() {
|
|||||||
applySession(session);
|
applySession(session);
|
||||||
stripOAuthParamsFromLocation();
|
stripOAuthParamsFromLocation();
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
navigate('/');
|
routeAfterShopLogin(session, navigate);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => setMsg(formatWechatError(e)));
|
.catch((e) => setMsg(formatWechatError(e)));
|
||||||
@@ -115,7 +116,7 @@ export default function LoginPage() {
|
|||||||
await bindShopWechatAfterSmsLogin();
|
await bindShopWechatAfterSmsLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate('/');
|
routeAfterShopLogin(data, navigate);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -135,7 +136,7 @@ export default function LoginPage() {
|
|||||||
const session = await loginShopWithWechat();
|
const session = await loginShopWithWechat();
|
||||||
if (session) {
|
if (session) {
|
||||||
applySession(session);
|
applySession(session);
|
||||||
navigate('/');
|
routeAfterShopLogin(session, navigate);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(formatWechatError(e));
|
setMsg(formatWechatError(e));
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
import { request } from '../lib/api';
|
import { getStoreProfile, request } from '../lib/api';
|
||||||
|
|
||||||
export default function MinePage() {
|
export default function MinePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { resetSession } = useStoreSession();
|
const { resetSession, store: sessionStore } = useStoreSession();
|
||||||
|
const profile = sessionStore ?? getStoreProfile();
|
||||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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 openTime = String(store?.openTime || '09:30');
|
||||||
const closeTime = String(store?.closeTime || '22:00');
|
const closeTime = String(store?.closeTime || '22:00');
|
||||||
|
const multiStore = (profile?.stores?.length ?? 0) > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-mine-page">
|
<div className="shop-mine-page">
|
||||||
@@ -28,7 +30,7 @@ export default function MinePage() {
|
|||||||
<div className="shop-mine-info-row">
|
<div className="shop-mine-info-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="shop-mine-info-label">门店名称</p>
|
<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>
|
</div>
|
||||||
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +44,7 @@ export default function MinePage() {
|
|||||||
<div className="shop-mine-info-row">
|
<div className="shop-mine-info-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="shop-mine-info-label">联系电话</p>
|
<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>
|
</div>
|
||||||
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +57,21 @@ export default function MinePage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="shop-mine-help">
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>help</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>help</span>
|
||||||
<p>如需修改信息请联系城市合伙人</p>
|
<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);
|
color: var(--color-muted);
|
||||||
font-size: 14px;
|
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',
|
PROMOTER = 'PROMOTER',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum StoreStaffRole {
|
||||||
|
MANAGER = 'MANAGER',
|
||||||
|
CASHIER = 'CASHIER',
|
||||||
|
}
|
||||||
|
|
||||||
export enum CityPartnerScopeType {
|
export enum CityPartnerScopeType {
|
||||||
CITY_WIDE = 'CITY_WIDE',
|
CITY_WIDE = 'CITY_WIDE',
|
||||||
DISTRICT = 'DISTRICT',
|
DISTRICT = 'DISTRICT',
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ export * from './partner-log';
|
|||||||
export * from './promo';
|
export * from './promo';
|
||||||
export * from './hq-permissions';
|
export * from './hq-permissions';
|
||||||
export * from './partner';
|
export * from './partner';
|
||||||
|
export * from './shop';
|
||||||
export * from './city-partner';
|
export * from './city-partner';
|
||||||
export * from './city-warehouse';
|
export * from './city-warehouse';
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export interface PartnerLeaderboardResponse {
|
|||||||
export interface PartnerStorePhoneAvailableResponse {
|
export interface PartnerStorePhoneAvailableResponse {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
/** 该手机号已是主账号时绑定的门店数;>0 时拓店需二次确认 */
|
||||||
|
existingStoreCount?: number;
|
||||||
|
needConfirm?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartnerWeeklyReportPeriod {
|
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
|
PROMOTER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum StoreStaffRole {
|
||||||
|
MANAGER
|
||||||
|
CASHIER
|
||||||
|
}
|
||||||
|
|
||||||
enum AccountStatus {
|
enum AccountStatus {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
DISABLED
|
DISABLED
|
||||||
@@ -697,9 +702,6 @@ model Store {
|
|||||||
status StoreStatus @default(PAUSED)
|
status StoreStatus @default(PAUSED)
|
||||||
openTime String? @map("open_time") @db.VarChar(8)
|
openTime String? @map("open_time") @db.VarChar(8)
|
||||||
closeTime String? @map("close_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)
|
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_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)
|
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
|
||||||
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
|
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
|
||||||
account StoreAccount?
|
bindings StoreAccountStore[]
|
||||||
redeemRecords RedeemRecord[]
|
redeemRecords RedeemRecord[]
|
||||||
redeemPendingRecords RedeemPendingRecord[]
|
redeemPendingRecords RedeemPendingRecord[]
|
||||||
ratings StoreRating[]
|
ratings StoreRating[]
|
||||||
@@ -721,22 +723,45 @@ model Store {
|
|||||||
|
|
||||||
model StoreAccount {
|
model StoreAccount {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
storeId BigInt @unique @map("store_id") @db.UnsignedBigInt
|
|
||||||
phone String @unique @db.VarChar(20)
|
phone String @unique @db.VarChar(20)
|
||||||
name String @db.VarChar(64)
|
name String @db.VarChar(64)
|
||||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||||
wxUnionId String? @map("wx_union_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)
|
status AccountStatus @default(ACTIVE)
|
||||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_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[]
|
redeemPendingRecords RedeemPendingRecord[]
|
||||||
|
|
||||||
|
@@index([parentAccountId])
|
||||||
@@map("store_account")
|
@@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 ────────────────────────────────────────────
|
// ─── ORDER ────────────────────────────────────────────
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
|
|||||||
@@ -429,12 +429,6 @@ async function main() {
|
|||||||
|
|
||||||
closeTime: '22:00',
|
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 } });
|
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 });
|
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 = (() => {
|
const weekStart = (() => {
|
||||||
@@ -511,12 +526,6 @@ async function main() {
|
|||||||
|
|
||||||
closeTime: '22:00',
|
closeTime: '22:00',
|
||||||
|
|
||||||
bankAccountName: '本周新签体验店',
|
|
||||||
|
|
||||||
bankAccountNo: '6222029876543211',
|
|
||||||
|
|
||||||
bankBranch: '农业银行郑州分行',
|
|
||||||
|
|
||||||
createdAt: new Date(weekStart.getTime() + 2 * 24 * 60 * 60 * 1000),
|
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 });
|
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({
|
await prisma.user.create({
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface AuthUser {
|
|||||||
clientApp: ClientApp;
|
clientApp: ClientApp;
|
||||||
sub: string;
|
sub: string;
|
||||||
phoneVerified: boolean;
|
phoneVerified: boolean;
|
||||||
|
/** Selected store after POST /shop/auth/select-store */
|
||||||
|
storeId?: bigint;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -41,6 +43,9 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
clientApp,
|
clientApp,
|
||||||
sub: payload.sub,
|
sub: payload.sub,
|
||||||
phoneVerified: !!payload.phoneVerified,
|
phoneVerified: !!payload.phoneVerified,
|
||||||
|
...(payload.storeId != null && payload.storeId !== ''
|
||||||
|
? { storeId: BigInt(payload.storeId) }
|
||||||
|
: {}),
|
||||||
} satisfies AuthUser;
|
} satisfies AuthUser;
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export class OptionalJwtAuthGuard implements CanActivate {
|
|||||||
clientApp,
|
clientApp,
|
||||||
sub: payload.sub,
|
sub: payload.sub,
|
||||||
phoneVerified: !!payload.phoneVerified,
|
phoneVerified: !!payload.phoneVerified,
|
||||||
|
...(payload.storeId != null && payload.storeId !== ''
|
||||||
|
? { storeId: BigInt(payload.storeId) }
|
||||||
|
: {}),
|
||||||
} satisfies AuthUser;
|
} satisfies AuthUser;
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore invalid token */
|
/* 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 & {
|
export type TrackStoreEventInput = TrackEventInput & {
|
||||||
storeAccountId?: bigint;
|
storeAccountId?: bigint;
|
||||||
storeId: bigint;
|
storeId?: bigint;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TrackPartnerEventInput = TrackEventInput & {
|
export type TrackPartnerEventInput = TrackEventInput & {
|
||||||
@@ -54,12 +54,14 @@ export class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async trackStoreOne(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
|
async trackStoreOne(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
|
||||||
|
if (event.storeId == null) return;
|
||||||
await this.prisma.logStoreAnalytics.create({
|
await this.prisma.logStoreAnalytics.create({
|
||||||
data: this.toStoreRow(storeAccountId, clientApp, event),
|
data: this.toStoreRow(storeAccountId, clientApp, event),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
trackStoreOneSafe(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
|
trackStoreOneSafe(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
|
||||||
|
if (event.storeId == null) return;
|
||||||
void this.trackStoreOne(storeAccountId, clientApp, event).catch(() => {});
|
void this.trackStoreOne(storeAccountId, clientApp, event).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@ export class AnalyticsService {
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
storeAccountId,
|
storeAccountId,
|
||||||
storeId: event.storeId,
|
storeId: event.storeId!,
|
||||||
eventName: event.eventName,
|
eventName: event.eventName,
|
||||||
clientApp: clientApp as ClientApp,
|
clientApp: clientApp as ClientApp,
|
||||||
refType: event.refType,
|
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 type { Request } from 'express';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import {
|
import {
|
||||||
@@ -122,6 +122,7 @@ export class ShopAuthController {
|
|||||||
dto.code,
|
dto.code,
|
||||||
ClientApp.SHOP_H5,
|
ClientApp.SHOP_H5,
|
||||||
dto.platform ?? 'h5',
|
dto.platform ?? 'h5',
|
||||||
|
user.storeId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
|
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);
|
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')
|
@Get('me')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
me(@CurrentUser() user: AuthUser) {
|
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(
|
private trackStoreEvent(
|
||||||
storeAccountId: bigint | undefined,
|
storeAccountId: bigint | undefined,
|
||||||
storeId: bigint,
|
storeId: bigint | undefined,
|
||||||
clientApp: ClientApp | string,
|
clientApp: ClientApp | string,
|
||||||
eventName: string,
|
eventName: string,
|
||||||
extraJson?: Record<string, unknown>,
|
extraJson?: Record<string, unknown>,
|
||||||
ref?: { refType?: string; refId?: bigint },
|
ref?: { refType?: string; refId?: bigint },
|
||||||
) {
|
) {
|
||||||
|
if (storeId == null) return;
|
||||||
this.analyticsService.trackStoreOneSafe(storeAccountId, clientApp, {
|
this.analyticsService.trackStoreOneSafe(storeAccountId, clientApp, {
|
||||||
storeId,
|
storeId,
|
||||||
eventName,
|
eventName,
|
||||||
@@ -393,14 +394,23 @@ export class AuthService {
|
|||||||
if (scene === SmsScene.STORE_LOGIN && actorRef?.refType === 'STORE') {
|
if (scene === SmsScene.STORE_LOGIN && actorRef?.refType === 'STORE') {
|
||||||
const storeAccount = await this.prisma.storeAccount.findUnique({
|
const storeAccount = await this.prisma.storeAccount.findUnique({
|
||||||
where: { id: actorRef.refId },
|
where: { id: actorRef.refId },
|
||||||
select: { id: true, storeId: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
bindings: { select: { storeId: true }, take: 1 },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (storeAccount) {
|
if (storeAccount) {
|
||||||
this.trackStoreEvent(storeAccount.id, storeAccount.storeId, clientApp, 'store_sms_send', {
|
this.trackStoreEvent(
|
||||||
|
storeAccount.id,
|
||||||
|
storeAccount.bindings[0]?.storeId,
|
||||||
|
clientApp,
|
||||||
|
'store_sms_send',
|
||||||
|
{
|
||||||
scene,
|
scene,
|
||||||
phone: this.maskPhone(normalizedPhone),
|
phone: this.maskPhone(normalizedPhone),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -487,7 +497,11 @@ export class AuthService {
|
|||||||
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||||
}
|
}
|
||||||
if (payload.actorType === 'STORE' && clientApp === ClientApp.SHOP_H5) {
|
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) {
|
if (payload.actorType === 'PARTNER' && clientApp === ClientApp.PARTNER_H5) {
|
||||||
return this.buildPartnerSessionResponse(BigInt(payload.actorId), clientApp);
|
return this.buildPartnerSessionResponse(BigInt(payload.actorId), clientApp);
|
||||||
@@ -499,21 +513,188 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildStoreSessionResponse(accountId: bigint, clientApp: ClientApp) {
|
private async loadStoreAccountWithBindings(accountId: bigint) {
|
||||||
const account = await this.prisma.storeAccount.findUnique({
|
return this.prisma.storeAccount.findUnique({
|
||||||
where: { id: accountId },
|
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') {
|
if (!account || account.status !== 'ACTIVE') {
|
||||||
throw new UnauthorizedException('Invalid refresh token');
|
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(),
|
id: account.id.toString(),
|
||||||
storeId: account.storeId.toString(),
|
storeId: selected?.id.toString() ?? '',
|
||||||
name: account.name,
|
name: account.name,
|
||||||
phone: account.phone,
|
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) {
|
private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) {
|
||||||
@@ -656,38 +837,42 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN);
|
await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN);
|
||||||
} catch (err) {
|
} 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) {
|
if (account) {
|
||||||
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_sms_verify_fail', {
|
this.trackStoreEvent(
|
||||||
|
account.id,
|
||||||
|
account.bindings[0]?.storeId,
|
||||||
|
clientApp,
|
||||||
|
'store_sms_verify_fail',
|
||||||
|
{
|
||||||
phone: this.maskPhone(normalizedPhone),
|
phone: this.maskPhone(normalizedPhone),
|
||||||
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const account = await this.prisma.storeAccount.findUnique({
|
const found = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone } });
|
||||||
where: { phone: normalizedPhone },
|
if (!found) throw new BadRequestException('该手机号未绑定门店');
|
||||||
include: { store: true },
|
const account = await this.loadStoreAccountWithBindings(found.id);
|
||||||
});
|
|
||||||
if (!account) throw new BadRequestException('该手机号未绑定门店');
|
if (!account) throw new BadRequestException('该手机号未绑定门店');
|
||||||
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
|
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
|
||||||
|
if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店');
|
||||||
await this.prisma.storeAccount.update({
|
await this.prisma.storeAccount.update({
|
||||||
where: { id: account.id },
|
where: { id: account.id },
|
||||||
data: { lastLoginAt: new Date() },
|
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),
|
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',
|
method: 'sms',
|
||||||
});
|
});
|
||||||
return this.issueToken('STORE', account.id, clientApp, false, undefined, {
|
return this.issueStoreSession(account, clientApp);
|
||||||
id: account.id.toString(),
|
|
||||||
storeId: account.storeId.toString(),
|
|
||||||
name: account.name,
|
|
||||||
phone: account.phone,
|
|
||||||
storeName: account.store.name,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginPartner(phone: string, code: string, clientApp: ClientApp) {
|
async loginPartner(phone: string, code: string, clientApp: ClientApp) {
|
||||||
@@ -814,11 +999,7 @@ export class AuthService {
|
|||||||
return this.formatUserProfile(user);
|
return this.formatUserProfile(user);
|
||||||
}
|
}
|
||||||
if (actorType === 'STORE') {
|
if (actorType === 'STORE') {
|
||||||
const account = await this.prisma.storeAccount.findUnique({
|
return this.getShopMe({ actorId });
|
||||||
where: { id: actorId },
|
|
||||||
include: { store: true },
|
|
||||||
});
|
|
||||||
return serializeBigInt(account);
|
|
||||||
}
|
}
|
||||||
if (actorType === 'PARTNER') {
|
if (actorType === 'PARTNER') {
|
||||||
const account = await this.prisma.partnerAccount.findUnique({
|
const account = await this.prisma.partnerAccount.findUnique({
|
||||||
@@ -1104,7 +1285,6 @@ export class AuthService {
|
|||||||
|
|
||||||
let account = await this.prisma.storeAccount.findFirst({
|
let account = await this.prisma.storeAccount.findFirst({
|
||||||
where: { wxOpenId: session.openId },
|
where: { wxOpenId: session.openId },
|
||||||
include: { store: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -1118,22 +1298,21 @@ export class AuthService {
|
|||||||
wxUnionId: session.unionId ?? account.wxUnionId,
|
wxUnionId: session.unionId ?? account.wxUnionId,
|
||||||
lastLoginAt: new Date(),
|
lastLoginAt: new Date(),
|
||||||
},
|
},
|
||||||
include: { store: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_wechat_login', { platform });
|
const full = await this.loadStoreAccountWithBindings(account.id);
|
||||||
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_login_success', {
|
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',
|
method: 'wechat',
|
||||||
platform,
|
platform,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.issueToken('STORE', account.id, clientApp, false, undefined, {
|
return this.issueStoreSession(full, clientApp);
|
||||||
id: account.id.toString(),
|
|
||||||
storeId: account.storeId.toString(),
|
|
||||||
name: account.name,
|
|
||||||
phone: account.phone,
|
|
||||||
storeName: account.store.name,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async bindStoreWechat(
|
async bindStoreWechat(
|
||||||
@@ -1141,6 +1320,7 @@ export class AuthService {
|
|||||||
code: string,
|
code: string,
|
||||||
clientApp: ClientApp,
|
clientApp: ClientApp,
|
||||||
platform: 'h5' | 'mini' = 'h5',
|
platform: 'h5' | 'mini' = 'h5',
|
||||||
|
currentStoreId?: bigint,
|
||||||
) {
|
) {
|
||||||
this.assertWechatEnabled();
|
this.assertWechatEnabled();
|
||||||
const session =
|
const session =
|
||||||
@@ -1150,7 +1330,6 @@ export class AuthService {
|
|||||||
|
|
||||||
const account = await this.prisma.storeAccount.findUnique({
|
const account = await this.prisma.storeAccount.findUnique({
|
||||||
where: { id: storeAccountId },
|
where: { id: storeAccountId },
|
||||||
include: { store: true },
|
|
||||||
});
|
});
|
||||||
if (!account) throw new BadRequestException('门店账号不存在');
|
if (!account) throw new BadRequestException('门店账号不存在');
|
||||||
|
|
||||||
@@ -1161,25 +1340,27 @@ export class AuthService {
|
|||||||
throw new BadRequestException('该微信已绑定其他门店账号');
|
throw new BadRequestException('该微信已绑定其他门店账号');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.storeAccount.update({
|
await this.prisma.storeAccount.update({
|
||||||
where: { id: storeAccountId },
|
where: { id: storeAccountId },
|
||||||
data: {
|
data: {
|
||||||
wxOpenId: session.openId,
|
wxOpenId: session.openId,
|
||||||
wxUnionId: session.unionId ?? account.wxUnionId,
|
wxUnionId: session.unionId ?? account.wxUnionId,
|
||||||
lastLoginAt: new Date(),
|
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, {
|
this.trackStoreEvent(
|
||||||
id: updated.id.toString(),
|
storeAccountId,
|
||||||
storeId: updated.storeId.toString(),
|
currentStoreId ?? full.bindings[0]?.store.id,
|
||||||
name: updated.name,
|
clientApp,
|
||||||
phone: updated.phone,
|
'store_wechat_bind',
|
||||||
storeName: updated.store.name,
|
{ platform },
|
||||||
});
|
);
|
||||||
|
|
||||||
|
return this.issueStoreSession(full, clientApp, currentStoreId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async bindPartnerWechat(
|
async bindPartnerWechat(
|
||||||
@@ -1497,6 +1678,8 @@ export class AuthService {
|
|||||||
partner?: Record<string, unknown>,
|
partner?: Record<string, unknown>,
|
||||||
deviceKey?: string | null,
|
deviceKey?: string | null,
|
||||||
hq?: Record<string, unknown>,
|
hq?: Record<string, unknown>,
|
||||||
|
storeId?: bigint,
|
||||||
|
shopExtra?: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
const payload = {
|
const payload = {
|
||||||
sub: actorId.toString(),
|
sub: actorId.toString(),
|
||||||
@@ -1504,6 +1687,7 @@ export class AuthService {
|
|||||||
actorId: actorId.toString(),
|
actorId: actorId.toString(),
|
||||||
clientApp,
|
clientApp,
|
||||||
phoneVerified,
|
phoneVerified,
|
||||||
|
...(storeId != null ? { storeId: storeId.toString() } : {}),
|
||||||
};
|
};
|
||||||
const accessToken = this.jwtService.sign(payload);
|
const accessToken = this.jwtService.sign(payload);
|
||||||
const refreshExpiresIn = actorType === 'STORE' || actorType === 'PARTNER' ? '7d' : '30d';
|
const refreshExpiresIn = actorType === 'STORE' || actorType === 'PARTNER' ? '7d' : '30d';
|
||||||
@@ -1519,6 +1703,7 @@ export class AuthService {
|
|||||||
store,
|
store,
|
||||||
partner,
|
partner,
|
||||||
hq,
|
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 { UserAddressService } from './user-address.service';
|
||||||
import { PartnerStaffController } from './partner-staff.controller';
|
import { PartnerStaffController } from './partner-staff.controller';
|
||||||
import { PartnerStaffService } from './partner-staff.service';
|
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 { AdminAuthController } from './admin-auth.controller';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
|
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
|
||||||
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -34,11 +38,37 @@ import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
|||||||
ShopAuthController,
|
ShopAuthController,
|
||||||
PartnerAuthController,
|
PartnerAuthController,
|
||||||
PartnerStaffController,
|
PartnerStaffController,
|
||||||
|
StoreStaffController,
|
||||||
UserProfileController,
|
UserProfileController,
|
||||||
UserAddressController,
|
UserAddressController,
|
||||||
AdminAuthController,
|
AdminAuthController,
|
||||||
],
|
],
|
||||||
providers: [AuthService, UserAddressService, PartnerStaffService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
|
providers: [
|
||||||
exports: [AuthService, UserAddressService, PartnerStaffService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
|
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 {}
|
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、用户编号或手机号');
|
throw new NotFoundException('用户不存在,请检查 ID、用户编号或手机号');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveStoreAccountId(storeId: string): Promise<bigint> {
|
private async resolveStoreAccountId(storeId: string): Promise<{ accountId: bigint; storeId: bigint }> {
|
||||||
const account = await this.prisma.storeAccount.findFirst({
|
const sid = this.parseStoreId(storeId);
|
||||||
where: { storeId: this.parseStoreId(storeId), status: 'ACTIVE' },
|
const binding = await this.prisma.storeAccountStore.findFirst({
|
||||||
|
where: {
|
||||||
|
storeId: sid,
|
||||||
|
storeAccount: { status: 'ACTIVE', isPrimary: 1 },
|
||||||
|
},
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { id: true, store: { select: { name: true } } },
|
select: { storeAccountId: true, storeId: true },
|
||||||
});
|
});
|
||||||
if (!account) {
|
if (!binding) {
|
||||||
throw new NotFoundException('该门店无可用账户,请先创建门店账户');
|
throw new NotFoundException('该门店无可用账户,请先创建门店账户');
|
||||||
}
|
}
|
||||||
return account.id;
|
return { accountId: binding.storeAccountId, storeId: binding.storeId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createToken(dto: AdminRedeemDebugCreateTokenDto) {
|
async createToken(dto: AdminRedeemDebugCreateTokenDto) {
|
||||||
@@ -76,32 +80,32 @@ export class AdminRedeemDebugService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async preview(dto: AdminRedeemDebugStoreTokenDto) {
|
async preview(dto: AdminRedeemDebugStoreTokenDto) {
|
||||||
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
|
||||||
return this.redeemService.previewRedeem(storeAccountId, dto.token);
|
return this.redeemService.previewRedeem(accountId, storeId, dto.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirm(dto: AdminRedeemDebugStoreTokenDto) {
|
async confirm(dto: AdminRedeemDebugStoreTokenDto) {
|
||||||
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
|
||||||
return this.redeemService.confirmRedeem(storeAccountId, { token: dto.token });
|
return this.redeemService.confirmRedeem(accountId, storeId, { token: dto.token });
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendPhoneLookupSms(dto: AdminRedeemDebugPhoneStoreDto) {
|
async sendPhoneLookupSms(dto: AdminRedeemDebugPhoneStoreDto) {
|
||||||
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
|
||||||
return this.redeemService.sendPhoneLookupSms(storeAccountId, dto.phone);
|
return this.redeemService.sendPhoneLookupSms(accountId, storeId, dto.phone);
|
||||||
}
|
}
|
||||||
|
|
||||||
async phoneBalance(dto: AdminRedeemDebugPhoneBalanceDto) {
|
async phoneBalance(dto: AdminRedeemDebugPhoneBalanceDto) {
|
||||||
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
|
||||||
return this.redeemService.verifyPhoneAndGetBalance(storeAccountId, dto.phone, dto.code);
|
return this.redeemService.verifyPhoneAndGetBalance(accountId, storeId, dto.phone, dto.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
async phonePrepare(dto: AdminRedeemDebugPhonePrepareDto) {
|
async phonePrepare(dto: AdminRedeemDebugPhonePrepareDto) {
|
||||||
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
|
||||||
return this.redeemService.preparePhoneRedeem(storeAccountId, dto.sessionId, dto.amount);
|
return this.redeemService.preparePhoneRedeem(accountId, storeId, dto.sessionId, dto.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
async phoneConfirm(dto: AdminRedeemDebugPhoneConfirmDto) {
|
async phoneConfirm(dto: AdminRedeemDebugPhoneConfirmDto) {
|
||||||
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
|
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
|
||||||
return this.redeemService.confirmPhoneRedeem(storeAccountId, dto.sessionId, dto.code);
|
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 };
|
if (query.phone) accountWhere.phone = { contains: query.phone };
|
||||||
const accounts = await this.prisma.storeAccount.findMany({
|
const accounts = await this.prisma.storeAccount.findMany({
|
||||||
where: accountWhere,
|
where: accountWhere,
|
||||||
select: { storeId: true },
|
select: {
|
||||||
|
bindings: { select: { storeId: true } },
|
||||||
|
},
|
||||||
take: 100,
|
take: 100,
|
||||||
});
|
});
|
||||||
if (accounts.length === 0) return [];
|
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) {
|
if (storeWhere.name) {
|
||||||
const stores = await this.prisma.store.findMany({
|
const stores = await this.prisma.store.findMany({
|
||||||
where: { id: { in: ids }, ...storeWhere },
|
where: { id: { in: ids }, ...storeWhere },
|
||||||
|
|||||||
@@ -41,14 +41,26 @@ export class AdminStoresService {
|
|||||||
include: {
|
include: {
|
||||||
cityRef: { select: { id: true, name: true, code: true } },
|
cityRef: { select: { id: true, name: true, code: true } },
|
||||||
partnerAccount: { select: { id: true, companyName: 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 } },
|
coverResource: { select: { id: true, url: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.store.count({ where }),
|
this.prisma.store.count({ where }),
|
||||||
]);
|
]);
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
items: items.map((s) => mapStoreCompat(s)),
|
items: items.map((s) =>
|
||||||
|
mapStoreCompat({
|
||||||
|
...s,
|
||||||
|
account: s.bindings[0]?.storeAccount ?? null,
|
||||||
|
bindings: undefined,
|
||||||
|
}),
|
||||||
|
),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -62,7 +74,11 @@ export class AdminStoresService {
|
|||||||
cityRef: true,
|
cityRef: true,
|
||||||
partnerAccount: true,
|
partnerAccount: true,
|
||||||
category: true,
|
category: true,
|
||||||
account: true,
|
bindings: {
|
||||||
|
where: { storeAccount: { isPrimary: 1 } },
|
||||||
|
take: 1,
|
||||||
|
include: { storeAccount: true },
|
||||||
|
},
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
_count: { select: { redeemRecords: true, ratings: true } },
|
_count: { select: { redeemRecords: true, ratings: true } },
|
||||||
},
|
},
|
||||||
@@ -81,6 +97,8 @@ export class AdminStoresService {
|
|||||||
]);
|
]);
|
||||||
return serializeBigInt(mapStoreCompat({
|
return serializeBigInt(mapStoreCompat({
|
||||||
...store,
|
...store,
|
||||||
|
account: store.bindings[0]?.storeAccount ?? null,
|
||||||
|
bindings: undefined,
|
||||||
media,
|
media,
|
||||||
audits,
|
audits,
|
||||||
redeemCount: store._count.redeemRecords,
|
redeemCount: store._count.redeemRecords,
|
||||||
@@ -165,7 +183,12 @@ export class AdminStoresService {
|
|||||||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||||
where: { phone: normalizedPhone },
|
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 partnerAccountId = BigInt(dto.partnerAccountId);
|
||||||
const partnerAccount = await this.prisma.partnerAccount.findUnique({
|
const partnerAccount = await this.prisma.partnerAccount.findUnique({
|
||||||
@@ -191,9 +214,6 @@ export class AdminStoresService {
|
|||||||
district: dto.district ?? '',
|
district: dto.district ?? '',
|
||||||
address: dto.address,
|
address: dto.address,
|
||||||
intro: dto.intro ?? null,
|
intro: dto.intro ?? null,
|
||||||
bankAccountName: dto.bankAccountName ?? null,
|
|
||||||
bankAccountNo: dto.bankAccountNo ?? null,
|
|
||||||
bankBranch: dto.bankBranch ?? null,
|
|
||||||
openTime: '10:00',
|
openTime: '10:00',
|
||||||
closeTime: '22:00',
|
closeTime: '22:00',
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
@@ -258,13 +278,37 @@ export class AdminStoresService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.prisma.storeAccount.create({
|
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: {
|
data: {
|
||||||
storeId: store.id,
|
...(bankAccountName != null ? { bankAccountName } : {}),
|
||||||
phone: normalizedPhone,
|
...(bankAccountNo != null ? { bankAccountNo } : {}),
|
||||||
name: dto.accountName ?? dto.name,
|
...(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);
|
return this.detailStore(store.id);
|
||||||
}
|
}
|
||||||
@@ -272,12 +316,37 @@ export class AdminStoresService {
|
|||||||
async createStoreAccount(dto: CreateStoreAccountDto) {
|
async createStoreAccount(dto: CreateStoreAccountDto) {
|
||||||
const store = await this.prisma.store.findUnique({
|
const store = await this.prisma.store.findUnique({
|
||||||
where: { id: BigInt(dto.storeId) },
|
where: { id: BigInt(dto.storeId) },
|
||||||
include: { account: true },
|
include: { bindings: true },
|
||||||
});
|
});
|
||||||
if (!store) throw new BadRequestException('门店不存在');
|
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({
|
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);
|
return serializeBigInt(account);
|
||||||
}
|
}
|
||||||
@@ -345,9 +414,11 @@ export class AdminStoresService {
|
|||||||
async listStoreAccounts(query: AdminStoreAccountsQueryDto) {
|
async listStoreAccounts(query: AdminStoreAccountsQueryDto) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 20;
|
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.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'];
|
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
@@ -356,22 +427,54 @@ export class AdminStoresService {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
|
include: {
|
||||||
|
bindings: {
|
||||||
include: {
|
include: {
|
||||||
store: { select: { id: true, name: true, status: true, cityName: true } },
|
store: { select: { id: true, name: true, status: true, cityName: true } },
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { childAccounts: true, bindings: true } },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.storeAccount.count({ where }),
|
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) {
|
async detailStoreAccount(id: bigint) {
|
||||||
const account = await this.prisma.storeAccount.findUnique({
|
const account = await this.prisma.storeAccount.findUnique({
|
||||||
where: { id },
|
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('门店账号不存在');
|
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) {
|
async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) {
|
||||||
|
|||||||
@@ -185,11 +185,17 @@ export class AdminWechatBindingsService {
|
|||||||
|
|
||||||
const accounts = await this.prisma.storeAccount.findMany({
|
const accounts = await this.prisma.storeAccount.findMany({
|
||||||
where,
|
where,
|
||||||
|
include: {
|
||||||
|
bindings: {
|
||||||
|
take: 1,
|
||||||
include: { store: { select: { id: true, name: true } } },
|
include: { store: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const a of accounts) {
|
for (const a of accounts) {
|
||||||
if (!a.wxOpenId) continue;
|
if (!a.wxOpenId) continue;
|
||||||
|
const firstStore = a.bindings[0]?.store;
|
||||||
rows.push({
|
rows.push({
|
||||||
actorType: 'STORE',
|
actorType: 'STORE',
|
||||||
actorId: a.id,
|
actorId: a.id,
|
||||||
@@ -197,8 +203,8 @@ export class AdminWechatBindingsService {
|
|||||||
name: a.name,
|
name: a.name,
|
||||||
wxOpenId: a.wxOpenId,
|
wxOpenId: a.wxOpenId,
|
||||||
wxUnionId: a.wxUnionId,
|
wxUnionId: a.wxUnionId,
|
||||||
refId: a.storeId,
|
refId: firstStore?.id,
|
||||||
refLabel: a.store.name,
|
refLabel: firstStore?.name ?? a.name,
|
||||||
lastLoginAt: a.lastLoginAt,
|
lastLoginAt: a.lastLoginAt,
|
||||||
status: a.status,
|
status: a.status,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { RedeemService } from './redeem.service';
|
import { RedeemService } from './redeem.service';
|
||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
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 { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import {
|
import {
|
||||||
RedeemPhoneBalanceDto,
|
RedeemPhoneBalanceDto,
|
||||||
@@ -37,18 +38,18 @@ export class UserRedeemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Controller('shop/redeem')
|
@Controller('shop/redeem')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||||
export class ShopRedeemController {
|
export class ShopRedeemController {
|
||||||
constructor(private readonly redeemService: RedeemService) {}
|
constructor(private readonly redeemService: RedeemService) {}
|
||||||
|
|
||||||
@Post('preview')
|
@Post('preview')
|
||||||
preview(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
|
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')
|
@Post('confirm')
|
||||||
confirm(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
|
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')
|
@Post('failures')
|
||||||
@@ -56,7 +57,7 @@ export class ShopRedeemController {
|
|||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
@Body() body: RedeemFailureReportDto,
|
@Body() body: RedeemFailureReportDto,
|
||||||
) {
|
) {
|
||||||
return this.redeemService.reportNetworkFailure(user.actorId, body);
|
return this.redeemService.reportNetworkFailure(user.actorId, user.storeId!, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('pending')
|
@Post('pending')
|
||||||
@@ -64,7 +65,7 @@ export class ShopRedeemController {
|
|||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
@Body() body: RedeemPendingSubmitDto,
|
@Body() body: RedeemPendingSubmitDto,
|
||||||
) {
|
) {
|
||||||
return this.redeemService.submitPendingRedeem(user.actorId, body);
|
return this.redeemService.submitPendingRedeem(user.actorId, user.storeId!, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('records')
|
@Get('records')
|
||||||
@@ -73,26 +74,46 @@ export class ShopRedeemController {
|
|||||||
@Query('page') page = '1',
|
@Query('page') page = '1',
|
||||||
@Query('pageSize') pageSize = '20',
|
@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')
|
@Post('phone/send-lookup-sms')
|
||||||
sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) {
|
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')
|
@Post('phone/balance')
|
||||||
phoneBalance(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneBalanceDto) {
|
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')
|
@Post('phone/prepare')
|
||||||
phonePrepare(@CurrentUser() user: AuthUser, @Body() body: RedeemPhonePrepareDto) {
|
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')
|
@Post('phone/confirm')
|
||||||
phoneConfirm(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneConfirmDto) {
|
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}`;
|
return `redeem:phone-session:${sessionId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadOpenStoreAccount(storeAccountId: bigint) {
|
private async loadOpenStoreAccount(storeAccountId: bigint, storeId: bigint) {
|
||||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
const binding = await this.prisma.storeAccountStore.findUnique({
|
||||||
where: { id: storeAccountId },
|
where: {
|
||||||
include: { store: true },
|
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('门店未营业');
|
throw new BadRequestException('门店未营业');
|
||||||
}
|
}
|
||||||
return account;
|
return {
|
||||||
|
...binding.storeAccount,
|
||||||
|
storeId: binding.store.id,
|
||||||
|
store: binding.store,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveUserByPhone(phone: string) {
|
private async resolveUserByPhone(phone: string) {
|
||||||
@@ -228,8 +241,8 @@ export class RedeemService {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendPhoneLookupSms(storeAccountId: bigint, phone: string) {
|
async sendPhoneLookupSms(storeAccountId: bigint, storeId: bigint, phone: string) {
|
||||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||||
const normalizedPhone = this.normalizeMobilePhone(phone);
|
const normalizedPhone = this.normalizeMobilePhone(phone);
|
||||||
await this.resolveUserByPhone(normalizedPhone);
|
await this.resolveUserByPhone(normalizedPhone);
|
||||||
await this.authService.sendSms(normalizedPhone, SmsScene.REDEEM_PHONE_LOOKUP, {
|
await this.authService.sendSms(normalizedPhone, SmsScene.REDEEM_PHONE_LOOKUP, {
|
||||||
@@ -243,8 +256,8 @@ export class RedeemService {
|
|||||||
return { ok: true, maskedPhone: this.maskPhoneForStore(normalizedPhone) };
|
return { ok: true, maskedPhone: this.maskPhoneForStore(normalizedPhone) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyPhoneAndGetBalance(storeAccountId: bigint, phone: string, code: string) {
|
async verifyPhoneAndGetBalance(storeAccountId: bigint, storeId: bigint, phone: string, code: string) {
|
||||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||||
const normalizedPhone = this.normalizeMobilePhone(phone);
|
const normalizedPhone = this.normalizeMobilePhone(phone);
|
||||||
const user = await this.resolveUserByPhone(normalizedPhone);
|
const user = await this.resolveUserByPhone(normalizedPhone);
|
||||||
await this.authService.verifySmsCode(normalizedPhone, code, SmsScene.REDEEM_PHONE_LOOKUP);
|
await this.authService.verifySmsCode(normalizedPhone, code, SmsScene.REDEEM_PHONE_LOOKUP);
|
||||||
@@ -298,8 +311,8 @@ export class RedeemService {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
async preparePhoneRedeem(storeAccountId: bigint, sessionId: string, amount: number) {
|
async preparePhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, amount: number) {
|
||||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||||
const session = await this.loadPhoneSession(sessionId, storeAccountId);
|
const session = await this.loadPhoneSession(sessionId, storeAccountId);
|
||||||
const userId = BigInt(session.userId);
|
const userId = BigInt(session.userId);
|
||||||
const { allocations } = await this.computeDirectAllocations(userId, amount);
|
const { allocations } = await this.computeDirectAllocations(userId, amount);
|
||||||
@@ -337,8 +350,8 @@ export class RedeemService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmPhoneRedeem(storeAccountId: bigint, sessionId: string, code: string) {
|
async confirmPhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, code: string) {
|
||||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||||
const session = await this.loadPhoneSession(sessionId, storeAccountId);
|
const session = await this.loadPhoneSession(sessionId, storeAccountId);
|
||||||
if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) {
|
if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) {
|
||||||
throw new BadRequestException('请先选择核销金额并发送确认验证码');
|
throw new BadRequestException('请先选择核销金额并发送确认验证码');
|
||||||
@@ -462,8 +475,8 @@ export class RedeemService {
|
|||||||
return { status: 'EXPIRED' as const };
|
return { status: 'EXPIRED' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
async previewRedeem(storeAccountId: bigint, token: string) {
|
async previewRedeem(storeAccountId: bigint, storeId: bigint, token: string) {
|
||||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||||
|
|
||||||
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
|
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
|
||||||
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
||||||
@@ -516,8 +529,8 @@ export class RedeemService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
|
async confirmRedeem(storeAccountId: bigint, storeId: bigint, body: { token: string }) {
|
||||||
const account = await this.loadOpenStoreAccount(storeAccountId);
|
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||||
const token = body.token?.trim();
|
const token = body.token?.trim();
|
||||||
if (!token) throw new BadRequestException('请提供核销码');
|
if (!token) throw new BadRequestException('请提供核销码');
|
||||||
|
|
||||||
@@ -594,6 +607,7 @@ export class RedeemService {
|
|||||||
|
|
||||||
async reportNetworkFailure(
|
async reportNetworkFailure(
|
||||||
storeAccountId: bigint,
|
storeAccountId: bigint,
|
||||||
|
storeId: bigint,
|
||||||
body: {
|
body: {
|
||||||
token: string;
|
token: string;
|
||||||
errorClass: 'NETWORK' | 'BUSINESS';
|
errorClass: 'NETWORK' | 'BUSINESS';
|
||||||
@@ -601,9 +615,7 @@ export class RedeemService {
|
|||||||
step: 'preview' | 'confirm';
|
step: 'preview' | 'confirm';
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||||
where: { id: storeAccountId },
|
|
||||||
});
|
|
||||||
const token = body.token.trim();
|
const token = body.token.trim();
|
||||||
if (!token) throw new BadRequestException('请提供核销码');
|
if (!token) throw new BadRequestException('请提供核销码');
|
||||||
|
|
||||||
@@ -618,7 +630,7 @@ export class RedeemService {
|
|||||||
const thresholdReached = failCount >= REDEEM_WEAKNET_FAIL_THRESHOLD;
|
const thresholdReached = failCount >= REDEEM_WEAKNET_FAIL_THRESHOLD;
|
||||||
|
|
||||||
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||||||
storeId: account.storeId,
|
storeId,
|
||||||
eventName: 'store_redeem_confirm_fail',
|
eventName: 'store_redeem_confirm_fail',
|
||||||
extraJson: {
|
extraJson: {
|
||||||
token,
|
token,
|
||||||
@@ -632,7 +644,7 @@ export class RedeemService {
|
|||||||
|
|
||||||
if (thresholdReached && body.errorClass === 'NETWORK') {
|
if (thresholdReached && body.errorClass === 'NETWORK') {
|
||||||
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||||||
storeId: account.storeId,
|
storeId,
|
||||||
eventName: 'store_redeem_weaknet_threshold',
|
eventName: 'store_redeem_weaknet_threshold',
|
||||||
extraJson: {
|
extraJson: {
|
||||||
token,
|
token,
|
||||||
@@ -670,9 +682,10 @@ export class RedeemService {
|
|||||||
|
|
||||||
async submitPendingRedeem(
|
async submitPendingRedeem(
|
||||||
storeAccountId: bigint,
|
storeAccountId: bigint,
|
||||||
|
storeId: bigint,
|
||||||
body: { token: string; photoResourceId: string; failCount?: number; remark?: string },
|
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();
|
const token = body.token.trim();
|
||||||
if (!token) throw new BadRequestException('请提供核销码');
|
if (!token) throw new BadRequestException('请提供核销码');
|
||||||
|
|
||||||
@@ -831,13 +844,7 @@ export class RedeemService {
|
|||||||
throw new BadRequestException('待处理单状态不可补核销');
|
throw new BadRequestException('待处理单状态不可补核销');
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
const account = await this.loadOpenStoreAccount(pending.storeAccountId, pending.storeId);
|
||||||
where: { id: pending.storeAccountId },
|
|
||||||
include: { store: true },
|
|
||||||
});
|
|
||||||
if (account.store.status !== 'OPEN') {
|
|
||||||
throw new BadRequestException('门店未营业,无法补核销');
|
|
||||||
}
|
|
||||||
|
|
||||||
const allocationsRaw = pending.allocationsJson as Array<{ couponId: string; amount: number }>;
|
const allocationsRaw = pending.allocationsJson as Array<{ couponId: string; amount: number }>;
|
||||||
const normalizedAllocations = allocationsRaw.map((item) => ({
|
const normalizedAllocations = allocationsRaw.map((item) => ({
|
||||||
@@ -941,42 +948,42 @@ export class RedeemService {
|
|||||||
return serializeBigInt(updated);
|
return serializeBigInt(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listShopRecords(storeAccountId: bigint, page = 1, pageSize = 20) {
|
async listShopRecords(storeAccountId: bigint, storeId: bigint, page = 1, pageSize = 20) {
|
||||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||||
where: { id: storeAccountId },
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
});
|
});
|
||||||
const [list, total] = await Promise.all([
|
const [list, total] = await Promise.all([
|
||||||
this.prisma.redeemRecord.findMany({
|
this.prisma.redeemRecord.findMany({
|
||||||
where: { storeId: account.storeId },
|
where: { storeId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: { payout: true },
|
include: { payout: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.redeemRecord.count({ where: { storeId: account.storeId } }),
|
this.prisma.redeemRecord.count({ where: { storeId } }),
|
||||||
]);
|
]);
|
||||||
return { list: serializeBigInt(list), total, page, pageSize };
|
return { list: serializeBigInt(list), total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getShopDashboard(storeAccountId: bigint) {
|
async getShopDashboard(storeAccountId: bigint, storeId: bigint) {
|
||||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||||
where: { id: storeAccountId },
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
include: { store: true },
|
include: { store: true },
|
||||||
});
|
});
|
||||||
const start = new Date();
|
const start = new Date();
|
||||||
start.setHours(0, 0, 0, 0);
|
start.setHours(0, 0, 0, 0);
|
||||||
const records = await this.prisma.redeemRecord.findMany({
|
const records = await this.prisma.redeemRecord.findMany({
|
||||||
where: { storeId: account.storeId, createdAt: { gte: start } },
|
where: { storeId, createdAt: { gte: start } },
|
||||||
});
|
});
|
||||||
const todayCount = records.length;
|
const todayCount = records.length;
|
||||||
const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0);
|
const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0);
|
||||||
const recent = await this.prisma.redeemRecord.findMany({
|
const recent = await this.prisma.redeemRecord.findMany({
|
||||||
where: { storeId: account.storeId },
|
where: { storeId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 3,
|
take: 3,
|
||||||
});
|
});
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
store: account.store,
|
store: binding.store,
|
||||||
todayCount,
|
todayCount,
|
||||||
todayAmount,
|
todayAmount,
|
||||||
recentRecords: recent,
|
recentRecords: recent,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
|
|||||||
import { SettlementService } from './settlement.service';
|
import { SettlementService } from './settlement.service';
|
||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.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 { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
@@ -20,7 +21,7 @@ export class SettlementController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Controller('shop/payouts')
|
@Controller('shop/payouts')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||||
export class ShopPayoutController {
|
export class ShopPayoutController {
|
||||||
constructor(private readonly settlementService: SettlementService) {}
|
constructor(private readonly settlementService: SettlementService) {}
|
||||||
|
|
||||||
@@ -30,7 +31,12 @@ export class ShopPayoutController {
|
|||||||
@Query('page') page = '1',
|
@Query('page') page = '1',
|
||||||
@Query('pageSize') pageSize = '20',
|
@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);
|
return serializeBigInt(payout);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listShopPayouts(storeAccountId: bigint, page = 1, pageSize = 20) {
|
async listShopPayouts(storeAccountId: bigint, storeId: bigint, page = 1, pageSize = 20) {
|
||||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||||
where: { id: storeAccountId },
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
});
|
});
|
||||||
const where = { storeId: account.storeId };
|
const where = { storeId };
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.storePayout.findMany({
|
this.prisma.storePayout.findMany({
|
||||||
where,
|
where,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { StoreService } from './store.service';
|
|||||||
import { RedeemService } from '../redeem/redeem.service';
|
import { RedeemService } from '../redeem/redeem.service';
|
||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.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 { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
@Controller('stores')
|
@Controller('stores')
|
||||||
@@ -105,28 +106,28 @@ export class PartnerReportController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Controller('shop/store')
|
@Controller('shop/store')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||||
export class ShopStoreController {
|
export class ShopStoreController {
|
||||||
constructor(private readonly storeService: StoreService) {}
|
constructor(private readonly storeService: StoreService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
info(@CurrentUser() user: AuthUser) {
|
info(@CurrentUser() user: AuthUser) {
|
||||||
return this.storeService.getShopStore(user.actorId);
|
return this.storeService.getShopStore(user.actorId, user.storeId!);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('status')
|
@Put('status')
|
||||||
status(@CurrentUser() user: AuthUser, @Body() body: { status: 'OPEN' | 'PAUSED' }) {
|
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')
|
@Controller('shop/dashboard')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||||
export class ShopDashboardController {
|
export class ShopDashboardController {
|
||||||
constructor(private readonly redeemService: RedeemService) {}
|
constructor(private readonly redeemService: RedeemService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async dashboard(@CurrentUser() user: AuthUser) {
|
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({
|
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||||
where: { phone: normalizedPhone },
|
where: { phone: normalizedPhone },
|
||||||
|
include: { _count: { select: { bindings: true } } },
|
||||||
});
|
});
|
||||||
if (existingAccount) {
|
if (!existingAccount) {
|
||||||
return { available: false, message: '该手机号已绑定门店' };
|
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>) {
|
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
||||||
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||||
const normalizedPhone = String(body.phone).trim();
|
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 city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
||||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||||
@@ -139,6 +156,10 @@ export class StoreService {
|
|||||||
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
|
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
|
||||||
if (!contractUrl) throw new BadRequestException('请上传签约合同');
|
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({
|
const store = await this.prisma.store.create({
|
||||||
data: {
|
data: {
|
||||||
cityId: city.id,
|
cityId: city.id,
|
||||||
@@ -151,9 +172,6 @@ export class StoreService {
|
|||||||
district: String(body.district ?? ''),
|
district: String(body.district ?? ''),
|
||||||
address: String(body.address),
|
address: String(body.address),
|
||||||
intro: body.intro ? String(body.intro) : null,
|
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',
|
openTime: body.openTime ? String(body.openTime) : '10:00',
|
||||||
closeTime: body.closeTime ? String(body.closeTime) : '22:00',
|
closeTime: body.closeTime ? String(body.closeTime) : '22:00',
|
||||||
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
|
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
|
||||||
@@ -219,14 +237,38 @@ export class StoreService {
|
|||||||
extraJson: body as never,
|
extraJson: body as never,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
void audit;
|
||||||
|
|
||||||
await this.prisma.storeAccount.create({
|
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: {
|
data: {
|
||||||
storeId: store.id,
|
...(bankAccountName != null ? { bankAccountName } : {}),
|
||||||
phone: normalizedPhone,
|
...(bankAccountNo != null ? { bankAccountNo } : {}),
|
||||||
name: String(body.name),
|
...(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', {
|
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||||
partnerAccountId: primaryId,
|
partnerAccountId: primaryId,
|
||||||
@@ -321,26 +363,26 @@ export class StoreService {
|
|||||||
return this.partnerGetStore(partnerAccountId, storeId);
|
return this.partnerGetStore(partnerAccountId, storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getShopStore(storeAccountId: bigint) {
|
async getShopStore(storeAccountId: bigint, storeId: bigint) {
|
||||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||||
where: { id: storeAccountId },
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
include: { store: { include: { category: true, coverResource: true } } },
|
include: { store: { include: { category: true, coverResource: true } } },
|
||||||
});
|
});
|
||||||
return serializeBigInt(mapStoreCompat(account.store));
|
return serializeBigInt(mapStoreCompat(binding.store));
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateShopStatus(storeAccountId: bigint, status: 'OPEN' | 'PAUSED') {
|
async updateShopStatus(storeAccountId: bigint, storeId: bigint, status: 'OPEN' | 'PAUSED') {
|
||||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||||
where: { id: storeAccountId },
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
include: { store: true },
|
include: { store: true },
|
||||||
});
|
});
|
||||||
const previousStatus = account.store.status;
|
const previousStatus = binding.store.status;
|
||||||
const store = await this.prisma.store.update({
|
const store = await this.prisma.store.update({
|
||||||
where: { id: account.storeId },
|
where: { id: storeId },
|
||||||
data: { status },
|
data: { status },
|
||||||
});
|
});
|
||||||
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
|
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
|
||||||
storeId: account.storeId,
|
storeId,
|
||||||
eventName: 'store_status_change',
|
eventName: 'store_status_change',
|
||||||
extraJson: {
|
extraJson: {
|
||||||
status,
|
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);
|
const result = await this.partnerCheckStorePhone(phone);
|
||||||
if (!result.available) {
|
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