diff --git a/apps/admin-web/src/pages/StoreAccountsPage.tsx b/apps/admin-web/src/pages/StoreAccountsPage.tsx index a415763..5e4c10e 100644 --- a/apps/admin-web/src/pages/StoreAccountsPage.tsx +++ b/apps/admin-web/src/pages/StoreAccountsPage.tsx @@ -7,9 +7,22 @@ import { request, type Paginated } from '../lib/api'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; +type StoreBrief = { id: string; name: string; status: string; cityName?: string }; + type Row = { - id: string; phone: string; name: string; status: string; createdAt: string; - store?: { id: string; name: string; status: string; cityName: string }; + id: string; + phone: string; + name: string; + status: string; + createdAt: string; + storeCount?: number; + staffCount?: number; + bankAccountName?: string | null; + bankAccountNo?: string | null; + bankBranch?: string | null; + store?: StoreBrief | null; + stores?: StoreBrief[]; + staff?: Array<{ id: string; name: string; phone: string; status: string; storeIds?: string[] }>; }; type StoreOption = { id: string; name: string }; @@ -41,17 +54,53 @@ export default function StoreAccountsPage() { const columns: ColumnsType = [ { title: '姓名', dataIndex: 'name', width: 100 }, { title: '手机', dataIndex: 'phone', width: 120 }, - { title: '门店', dataIndex: ['store', 'name'], width: 140 }, - { title: '门店状态', dataIndex: ['store', 'status'], width: 90, render: (s) => s ? {STORE_STATUS_LABELS[s] || s} : '—' }, - { title: '账号状态', dataIndex: 'status', width: 90, render: (s) => {ACCOUNT_STATUS_LABELS[s] || s} }, + { + title: '绑定门店', + width: 180, + render: (_, row) => + row.stores?.length + ? row.stores.map((s) => s.name).join('、') + : row.store?.name ?? '—', + }, + { + title: '门店数', + dataIndex: 'storeCount', + width: 70, + render: (n, row) => n ?? row.stores?.length ?? 0, + }, + { + title: '子账号', + dataIndex: 'staffCount', + width: 70, + render: (n) => n ?? 0, + }, + { + title: '收款户名', + dataIndex: 'bankAccountName', + width: 120, + render: (v) => v || '—', + }, + { + title: '账号状态', + dataIndex: 'status', + width: 90, + render: (s) => {ACCOUNT_STATUS_LABELS[s] || s}, + }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { - title: '操作', width: 80, + title: '操作', + width: 80, render: (_, row) => ( - + ), }, ]; @@ -61,48 +110,141 @@ export default function StoreAccountsPage() { 门店账户 - 新建门店时自动开通主账号;「新建账户」仅用于补录历史无账号门店 + + 主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店 + - + -
{ setFilters(v); setPage(1); }}> + { + setFilters(v); + setPage(1); + }} + > - ({ value, label }))} + />
- { setPage(p); setPageSize(ps); } }} /> - setDrawerOpen(false)} - extra={detail && ( -
{ + setPage(p); + setPageSize(ps); + }, + }} + /> + setDrawerOpen(false)} + extra={ + detail && ( +
{ACCOUNT_STATUS_LABELS[s] || s}, + }, + ]} + /> + + ) : null} + )} - setCreateOpen(false)} onOk={async () => { - const v = await createForm.validateFields(); - await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) }); - message.success('已创建'); - setCreateOpen(false); - createForm.resetFields(); - void reload(); - }}> + setCreateOpen(false)} + onOk={async () => { + const v = await createForm.validateFields(); + await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) }); + message.success('已创建'); + setCreateOpen(false); + createForm.resetFields(); + void reload(); + }} + >
- ({ value: s.id, label: s.name }))} + /> diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index a01345a..2577973 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -310,6 +310,16 @@ export default function StoreCreatePage() { setFieldErrors({ phone: phoneMsg }); return; } + if (phoneCheck.needConfirm) { + const ok = window.confirm( + phoneCheck.message ?? + `该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`, + ); + if (!ok) { + setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' }); + return; + } + } } catch (e) { reportFormError(e instanceof Error ? e.message : '手机号校验失败'); @@ -381,6 +391,8 @@ export default function StoreCreatePage() { setFieldErrors({}); + let confirmBindExisting = false; + try { const phoneCheck = await checkStorePhoneAvailable(form.phone.trim()); @@ -397,6 +409,18 @@ export default function StoreCreatePage() { } + if (phoneCheck.needConfirm) { + const ok = window.confirm( + phoneCheck.message ?? + `该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`, + ); + if (!ok) { + setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' }); + return; + } + confirmBindExisting = true; + } + } catch (e) { reportFormError(e instanceof Error ? e.message : '手机号校验失败'); return; @@ -440,6 +464,8 @@ export default function StoreCreatePage() { bankBranch: form.bankBranch.trim(), + ...(confirmBindExisting ? { confirmBindExisting: true } : {}), + }), }); diff --git a/apps/h5-shop/src/App.tsx b/apps/h5-shop/src/App.tsx index 27c13b8..1d9790f 100644 --- a/apps/h5-shop/src/App.tsx +++ b/apps/h5-shop/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import AuthGate from './components/AuthGate'; import TabLayout from './layouts/TabLayout'; import LoginPage from './pages/LoginPage'; +import SelectStorePage from './pages/SelectStorePage'; import HomePage from './pages/HomePage'; import RedeemConfirmPage from './pages/RedeemConfirmPage'; import PhoneRedeemPage from './pages/PhoneRedeemPage'; @@ -9,12 +10,15 @@ import RedeemSuccessPage from './pages/RedeemSuccessPage'; import RecordsPage from './pages/RecordsPage'; import StatusPage from './pages/StatusPage'; import MinePage from './pages/MinePage'; +import StaffPage from './pages/StaffPage'; export default function App() { return ( } /> + } /> + } /> } /> } /> } /> diff --git a/apps/h5-shop/src/components/AuthGate.tsx b/apps/h5-shop/src/components/AuthGate.tsx index 28f8cd4..e297e17 100644 --- a/apps/h5-shop/src/components/AuthGate.tsx +++ b/apps/h5-shop/src/components/AuthGate.tsx @@ -3,9 +3,10 @@ import { getStoreProfile, hasShopWxSession } from '../lib/api'; import { useStoreSession } from '../contexts/StoreSessionContext'; const PUBLIC_PATHS = new Set(['/login']); +const SELECT_STORE_PATH = '/select-store'; export default function AuthGate({ children }: { children: React.ReactNode }) { - const { ready, authenticated } = useStoreSession(); + const { ready, authenticated, needsSelectStore } = useStoreSession(); const location = useLocation(); if (!ready) { @@ -17,7 +18,11 @@ export default function AuthGate({ children }: { children: React.ReactNode }) { } if (authenticated && location.pathname === '/login') { - return ; + return ; + } + + if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) { + return ; } if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { diff --git a/apps/h5-shop/src/contexts/StoreSessionContext.tsx b/apps/h5-shop/src/contexts/StoreSessionContext.tsx index 342b247..edd3d32 100644 --- a/apps/h5-shop/src/contexts/StoreSessionContext.tsx +++ b/apps/h5-shop/src/contexts/StoreSessionContext.tsx @@ -18,6 +18,7 @@ import { type StoreSessionContextValue = { ready: boolean; authenticated: boolean; + needsSelectStore: boolean; store: StoreSessionStore | null; applySession: (session: ShopSessionPayload) => void; resetSession: () => void; @@ -28,17 +29,29 @@ const StoreSessionContext = createContext(null) export function StoreSessionProvider({ children }: { children: ReactNode }) { const [ready, setReady] = useState(false); const [authenticated, setAuthenticated] = useState(false); + const [needsSelectStore, setNeedsSelectStore] = useState(false); const [store, setStore] = useState(null); const applySession = useCallback((session: ShopSessionPayload) => { saveAuth(session); setAuthenticated(true); - if (session.store) setStore(session.store); + const nextStore = session.store + ? { + ...session.store, + stores: session.stores ?? session.store.stores, + isPrimary: session.account?.isPrimary ?? session.store.isPrimary, + } + : null; + setStore(nextStore); + const storeId = nextStore?.storeId || session.selectedStoreId || ''; + const stores = session.stores ?? nextStore?.stores ?? []; + setNeedsSelectStore(stores.length > 1 && !storeId); }, []); const resetSession = useCallback(() => { clearAuth(); setAuthenticated(false); + setNeedsSelectStore(false); setStore(null); }, []); @@ -50,6 +63,7 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) { if (cancelled) return; setAuthenticated(result.authenticated); setStore(result.store); + setNeedsSelectStore(result.needsSelectStore); } catch { if (!cancelled) resetSession(); } finally { @@ -62,8 +76,8 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) { }, [resetSession]); const value = useMemo( - () => ({ ready, authenticated, store, applySession, resetSession }), - [ready, authenticated, store, applySession, resetSession], + () => ({ ready, authenticated, needsSelectStore, store, applySession, resetSession }), + [ready, authenticated, needsSelectStore, store, applySession, resetSession], ); return {children}; diff --git a/apps/h5-shop/src/lib/api.ts b/apps/h5-shop/src/lib/api.ts index 7a72a41..9626a69 100644 --- a/apps/h5-shop/src/lib/api.ts +++ b/apps/h5-shop/src/lib/api.ts @@ -1,12 +1,22 @@ export const apiBase = '/api/v1'; const CLIENT_APP = 'SHOP_H5'; +export type ShopStoreOption = { + storeId: string; + name: string; + status: string; + district?: string; + address?: string; +}; + export type StoreSessionStore = { id: string; storeId: string; name: string; phone: string; storeName: string; + isPrimary?: boolean; + stores?: ShopStoreOption[]; }; export type StoreProfile = { @@ -15,13 +25,25 @@ export type StoreProfile = { name: string; phone: string; status?: string; - store?: { id: string; name: string }; + isPrimary?: boolean; + account?: { + id: string; + name: string; + phone: string; + isPrimary: boolean; + hasWechat?: boolean; + }; + store?: ShopStoreOption | { id: string; name: string } | null; + stores?: ShopStoreOption[]; }; export type ShopSessionPayload = { accessToken: string; refreshToken: string; store?: StoreSessionStore; + stores?: ShopStoreOption[]; + account?: StoreProfile['account']; + selectedStoreId?: string; }; const ACCESS_TOKEN = 'accessToken'; @@ -73,8 +95,27 @@ export function saveAuth(data: ShopSessionPayload) { localStorage.setItem(ACCESS_TOKEN, data.accessToken); if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken); if (data.store) { - localStorage.setItem(STORE_PROFILE, JSON.stringify(data.store)); - localStorage.setItem(LAST_PHONE, data.store.phone); + const profile: StoreSessionStore = { + ...data.store, + stores: data.stores ?? data.store.stores, + isPrimary: data.account?.isPrimary ?? data.store.isPrimary, + }; + localStorage.setItem(STORE_PROFILE, JSON.stringify(profile)); + if (data.store.phone) localStorage.setItem(LAST_PHONE, data.store.phone); + } else if (data.account) { + localStorage.setItem( + STORE_PROFILE, + JSON.stringify({ + id: data.account.id, + storeId: '', + name: data.account.name, + phone: data.account.phone, + storeName: '', + isPrimary: data.account.isPrimary, + stores: data.stores ?? [], + } satisfies StoreSessionStore), + ); + localStorage.setItem(LAST_PHONE, data.account.phone); } } @@ -101,15 +142,45 @@ export function isLoggedIn() { } function profileFromMe(me: StoreProfile): StoreSessionStore { + const selected = + me.store && 'storeId' in me.store + ? me.store + : me.store && 'id' in me.store + ? { storeId: String((me.store as { id: string }).id), name: me.store.name } + : null; return { - id: me.id, - storeId: me.storeId, - name: me.name, - phone: me.phone, - storeName: me.store?.name ?? me.name, + id: me.account?.id ?? me.id, + storeId: selected?.storeId ?? me.storeId ?? '', + name: me.account?.name ?? me.name, + phone: me.account?.phone ?? me.phone, + storeName: selected?.name ?? '', + isPrimary: me.account?.isPrimary ?? me.isPrimary, + stores: me.stores ?? [], }; } +export function needsStoreSelection(session: { + store?: StoreSessionStore | null; + stores?: ShopStoreOption[]; + selectedStoreId?: string; +}): boolean { + const storeId = session.store?.storeId || session.selectedStoreId || ''; + const stores = session.stores ?? session.store?.stores ?? []; + if (stores.length > 1 && !storeId) return true; + if (!storeId && stores.length !== 1) return true; + return false; +} + +export async function selectStore(storeId: string): Promise { + const data = await requestWithAuthRetry('/shop/auth/select-store', { + method: 'POST', + body: JSON.stringify({ storeId }), + }); + saveAuth(data); + touchShopSession(); + return data; +} + async function rawRequest( path: string, options: RequestInit = {}, @@ -190,13 +261,17 @@ export async function request(clientApp: string, path: string, options: Reque return requestWithAuthRetry(path, options); } -export async function ensureSession(): Promise<{ authenticated: boolean; store: StoreSessionStore | null }> { +export async function ensureSession(): Promise<{ + authenticated: boolean; + store: StoreSessionStore | null; + needsSelectStore: boolean; +}> { if (!isLoggedIn()) { - return { authenticated: false, store: null }; + return { authenticated: false, store: null, needsSelectStore: false }; } if (isShopSessionExpired()) { clearAuth({ keepProfile: true }); - return { authenticated: false, store: getStoreProfile() }; + return { authenticated: false, store: getStoreProfile(), needsSelectStore: false }; } try { const me = await rawRequest('/shop/auth/me'); @@ -205,21 +280,29 @@ export async function ensureSession(): Promise<{ authenticated: boolean; store: accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '', refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '', store, + stores: me.stores, + account: me.account, }); touchShopSession(); - return { authenticated: true, store }; + return { + authenticated: true, + store, + needsSelectStore: needsStoreSelection({ store, stores: me.stores }), + }; } catch (e) { const err = e as Error & { status?: number }; if (err.status === 401) { const refreshed = await refreshSession(); - if (refreshed?.store) { - return { authenticated: true, store: refreshed.store }; + if (refreshed) { + return { + authenticated: true, + store: refreshed.store ?? getStoreProfile(), + needsSelectStore: needsStoreSelection(refreshed), + }; } clearAuth({ keepProfile: true }); - return { authenticated: false, store: getStoreProfile() }; + return { authenticated: false, store: getStoreProfile(), needsSelectStore: false }; } - const cached = getStoreProfile(); - if (cached) return { authenticated: true, store: cached }; throw e; } } diff --git a/apps/h5-shop/src/lib/wechat-auth.ts b/apps/h5-shop/src/lib/wechat-auth.ts index c6915f7..a9c3f15 100644 --- a/apps/h5-shop/src/lib/wechat-auth.ts +++ b/apps/h5-shop/src/lib/wechat-auth.ts @@ -36,17 +36,26 @@ export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null): export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPayload | null { if (!result.accessToken || !result.refreshToken) return null; - const store = result.store; + const store = result.store as Record | undefined; + const stores = Array.isArray((result as { stores?: unknown }).stores) + ? ((result as { stores: ShopSessionPayload['stores'] }).stores) + : undefined; + const account = (result as { account?: ShopSessionPayload['account'] }).account; return { accessToken: result.accessToken, refreshToken: result.refreshToken, + stores, + account, + selectedStoreId: (result as { selectedStoreId?: string }).selectedStoreId, store: store ? { - id: String(store.id ?? ''), + id: String(store.id ?? account?.id ?? ''), storeId: String(store.storeId ?? ''), - name: String(store.name ?? ''), - phone: String(store.phone ?? ''), + name: String(store.name ?? account?.name ?? ''), + phone: String(store.phone ?? account?.phone ?? ''), storeName: String(store.storeName ?? store.name ?? ''), + isPrimary: account?.isPrimary, + stores, } : undefined, }; diff --git a/apps/h5-shop/src/pages/LoginPage.tsx b/apps/h5-shop/src/pages/LoginPage.tsx index c54816a..722daa6 100644 --- a/apps/h5-shop/src/pages/LoginPage.tsx +++ b/apps/h5-shop/src/pages/LoginPage.tsx @@ -5,6 +5,7 @@ import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; import { useStoreSession } from '../contexts/StoreSessionContext'; import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api'; +import { routeAfterShopLogin } from './SelectStorePage'; import { bindShopWechatAfterSmsLogin, fetchClientConfig, @@ -58,7 +59,7 @@ export default function LoginPage() { applySession(session); stripOAuthParamsFromLocation(); setSearchParams({}, { replace: true }); - navigate('/'); + routeAfterShopLogin(session, navigate); } }) .catch((e) => setMsg(formatWechatError(e))); @@ -115,7 +116,7 @@ export default function LoginPage() { await bindShopWechatAfterSmsLogin(); return; } - navigate('/'); + routeAfterShopLogin(data, navigate); } catch (e) { setMsg(e instanceof Error ? e.message : '登录失败'); } finally { @@ -135,7 +136,7 @@ export default function LoginPage() { const session = await loginShopWithWechat(); if (session) { applySession(session); - navigate('/'); + routeAfterShopLogin(session, navigate); } } catch (e) { setMsg(formatWechatError(e)); diff --git a/apps/h5-shop/src/pages/MinePage.tsx b/apps/h5-shop/src/pages/MinePage.tsx index 98fc313..1600bc4 100644 --- a/apps/h5-shop/src/pages/MinePage.tsx +++ b/apps/h5-shop/src/pages/MinePage.tsx @@ -1,19 +1,21 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useStoreSession } from '../contexts/StoreSessionContext'; -import { request } from '../lib/api'; +import { getStoreProfile, request } from '../lib/api'; export default function MinePage() { const navigate = useNavigate(); - const { resetSession } = useStoreSession(); + const { resetSession, store: sessionStore } = useStoreSession(); + const profile = sessionStore ?? getStoreProfile(); const [store, setStore] = useState | null>(null); useEffect(() => { - request('SHOP_H5', '/shop/store').then(setStore); + request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null)); }, []); const openTime = String(store?.openTime || '09:30'); const closeTime = String(store?.closeTime || '22:00'); + const multiStore = (profile?.stores?.length ?? 0) > 1; return (
@@ -28,7 +30,7 @@ export default function MinePage() {

门店名称

-

{String(store?.name || '—')}

+

{String(store?.name || profile?.storeName || '—')}

lock
@@ -42,7 +44,7 @@ export default function MinePage() {

联系电话

-

{String(store?.phone || '—')}

+

{String(store?.phone || profile?.phone || '—')}

lock
@@ -55,6 +57,21 @@ export default function MinePage() {
+
+ {multiStore ? ( + + ) : null} + {profile?.isPrimary ? ( + + ) : null} +
+
help

如需修改信息请联系城市合伙人

diff --git a/apps/h5-shop/src/pages/SelectStorePage.tsx b/apps/h5-shop/src/pages/SelectStorePage.tsx new file mode 100644 index 0000000..67e945c --- /dev/null +++ b/apps/h5-shop/src/pages/SelectStorePage.tsx @@ -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(store?.stores ?? []); + const [loading, setLoading] = useState(false); + const [msg, setMsg] = useState(''); + + useEffect(() => { + if (!authenticated) { + navigate('/login', { replace: true }); + return; + } + void request('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 ( +
+
+

选择门店

+

该账号绑定了多家门店,请选择本次要进入的门店

+
+ {msg ?

{msg}

: null} +
    + {stores.map((item) => ( +
  • + +
  • + ))} +
+ {!stores.length && !msg ?

暂无绑定门店

: null} +
+ ); +} + +/** 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 }); +} diff --git a/apps/h5-shop/src/pages/StaffPage.tsx b/apps/h5-shop/src/pages/StaffPage.tsx new file mode 100644 index 0000000..e6ea8ab --- /dev/null +++ b/apps/h5-shop/src/pages/StaffPage.tsx @@ -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([]); + const [ownedStores, setOwnedStores] = useState([]); + 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('SHOP_H5', '/shop/staff'), + request('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 ( +
+
+ +

子账号管理

+ +
+ + {msg ?

{msg}

: null} + + {showForm ? ( +
+ setForm((f) => ({ ...f, phone: e.target.value }))} + /> + setForm((f) => ({ ...f, name: e.target.value }))} + /> +

绑定门店

+
+ {ownedStores.map((s) => ( + + ))} +
+ +
+ ) : null} + +
    + {list.map((item) => ( +
  • +
    + {item.name} + {item.phone} + + {STORE_STAFF_ROLE_LABELS[item.staffRole] ?? item.staffRole} ·{' '} + {item.status === 'ACTIVE' ? '启用' : '停用'} + + {item.stores.map((s) => s.name).join('、')} +
    + +
  • + ))} +
+ {!list.length ?

暂无子账号

: null} +
+ ); +} diff --git a/apps/h5-shop/src/styles.css b/apps/h5-shop/src/styles.css index 748a039..940dcc5 100644 --- a/apps/h5-shop/src/styles.css +++ b/apps/h5-shop/src/styles.css @@ -2183,3 +2183,163 @@ color: var(--color-muted); font-size: 14px; } + +.shop-select-store-page, +.shop-staff-page { + min-height: 100vh; + padding: 24px 16px 40px; + background: var(--color-background); +} + +.shop-select-store-header h1, +.shop-staff-header h1 { + margin: 0; + font-family: var(--font-headline); + font-size: 22px; +} + +.shop-select-store-header p { + margin: 8px 0 20px; + color: var(--color-muted); + font-size: 14px; +} + +.shop-select-store-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.shop-select-store-item { + width: 100%; + text-align: left; + padding: 16px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 12px; + background: #fff; + cursor: pointer; +} + +.shop-select-store-name { + display: block; + font-size: 16px; + font-weight: 600; +} + +.shop-select-store-meta { + display: block; + margin-top: 4px; + font-size: 13px; + color: var(--color-muted); +} + +.shop-select-store-msg, +.shop-staff-msg { + color: var(--color-heritage-red); + font-size: 14px; +} + +.shop-mine-actions { + display: flex; + flex-direction: column; + gap: 10px; + margin: 16px 0; +} + +.shop-mine-action { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 44px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 10px; + background: #fff; + font-size: 15px; + cursor: pointer; + padding: 0 14px; +} + +.shop-staff-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.shop-staff-back, +.shop-staff-add { + border: none; + background: transparent; + color: var(--color-heritage-red); + font-size: 14px; + cursor: pointer; +} + +.shop-staff-form { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; + padding: 14px; + border-radius: 12px; + background: #fff; +} + +.shop-staff-form input { + height: 40px; + padding: 0 12px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; +} + +.shop-staff-store-picks { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 14px; +} + +.shop-staff-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.shop-staff-item { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 14px; + border-radius: 12px; + background: #fff; +} + +.shop-staff-item div { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + color: var(--color-muted); +} + +.shop-staff-item strong { + color: #222; + font-size: 15px; +} + +.shop-staff-item button { + align-self: center; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + background: #fff; + padding: 6px 10px; + cursor: pointer; +} + diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index 80d97a9..43b4b3e 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -109,6 +109,11 @@ export enum PartnerStaffRole { PROMOTER = 'PROMOTER', } +export enum StoreStaffRole { + MANAGER = 'MANAGER', + CASHIER = 'CASHIER', +} + export enum CityPartnerScopeType { CITY_WIDE = 'CITY_WIDE', DISTRICT = 'DISTRICT', diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 0c5aaef..cc88604 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -15,5 +15,6 @@ export * from './partner-log'; export * from './promo'; export * from './hq-permissions'; export * from './partner'; +export * from './shop'; export * from './city-partner'; export * from './city-warehouse'; diff --git a/packages/shared-types/src/partner.ts b/packages/shared-types/src/partner.ts index b6f08ba..dc6d894 100644 --- a/packages/shared-types/src/partner.ts +++ b/packages/shared-types/src/partner.ts @@ -63,6 +63,9 @@ export interface PartnerLeaderboardResponse { export interface PartnerStorePhoneAvailableResponse { available: boolean; message?: string; + /** 该手机号已是主账号时绑定的门店数;>0 时拓店需二次确认 */ + existingStoreCount?: number; + needConfirm?: boolean; } export interface PartnerWeeklyReportPeriod { diff --git a/packages/shared-types/src/shop.ts b/packages/shared-types/src/shop.ts new file mode 100644 index 0000000..e10ffe4 --- /dev/null +++ b/packages/shared-types/src/shop.ts @@ -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 = { + MANAGER: '店长', + CASHIER: '收银员', +}; + +/** Default permissions for store sub-accounts (首版). */ +export const STORE_STAFF_DEFAULT_PERMISSIONS = ['redeem', 'records'] as const; diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index e53fb74..6635044 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -147,6 +147,11 @@ enum PartnerStaffRole { PROMOTER } +enum StoreStaffRole { + MANAGER + CASHIER +} + enum AccountStatus { ACTIVE DISABLED @@ -697,9 +702,6 @@ model Store { status StoreStatus @default(PAUSED) openTime String? @map("open_time") @db.VarChar(8) closeTime String? @map("close_time") @db.VarChar(8) - bankAccountName String? @map("bank_account_name") @db.VarChar(64) - bankAccountNo String? @map("bank_account_no") @db.VarChar(32) - bankBranch String? @map("bank_branch") @db.VarChar(128) settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) @@ -708,7 +710,7 @@ model Store { partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict) category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull) - account StoreAccount? + bindings StoreAccountStore[] redeemRecords RedeemRecord[] redeemPendingRecords RedeemPendingRecord[] ratings StoreRating[] @@ -720,23 +722,46 @@ model Store { } model StoreAccount { - id BigInt @id @default(autoincrement()) @db.UnsignedBigInt - storeId BigInt @unique @map("store_id") @db.UnsignedBigInt - phone String @unique @db.VarChar(20) - name String @db.VarChar(64) - wxOpenId String? @map("wx_open_id") @db.VarChar(64) - wxUnionId String? @map("wx_union_id") @db.VarChar(64) - status AccountStatus @default(ACTIVE) - lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3) - createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) - updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + phone String @unique @db.VarChar(20) + name String @db.VarChar(64) + wxOpenId String? @map("wx_open_id") @db.VarChar(64) + wxUnionId String? @map("wx_union_id") @db.VarChar(64) + isPrimary Int @default(1) @map("is_primary") @db.TinyInt + parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt + staffRole StoreStaffRole? @map("staff_role") + permissions Json? + bankAccountName String? @map("bank_account_name") @db.VarChar(64) + bankAccountNo String? @map("bank_account_no") @db.VarChar(32) + bankBranch String? @map("bank_branch") @db.VarChar(128) + status AccountStatus @default(ACTIVE) + lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + parentAccount StoreAccount? @relation("StoreAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: Restrict) + childAccounts StoreAccount[] @relation("StoreAccountHierarchy") + bindings StoreAccountStore[] redeemPendingRecords RedeemPendingRecord[] + @@index([parentAccountId]) @@map("store_account") } +model StoreAccountStore { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + storeAccountId BigInt @map("store_account_id") @db.UnsignedBigInt + storeId BigInt @map("store_id") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + storeAccount StoreAccount @relation(fields: [storeAccountId], references: [id], onDelete: Cascade) + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + @@unique([storeAccountId, storeId]) + @@index([storeId]) + @@map("store_account_store") +} + // ─── ORDER ──────────────────────────────────────────── model Order { diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts index 0f0f079..57c698c 100644 --- a/server/dukang-api/prisma/seed-v31.ts +++ b/server/dukang-api/prisma/seed-v31.ts @@ -429,12 +429,6 @@ async function main() { closeTime: '22:00', - bankAccountName: def.name, - - bankAccountNo: '6222029876543210', - - bankBranch: '建设银行郑州分行', - }, }); @@ -443,20 +437,41 @@ async function main() { await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } }); - if (def.withAccount) { - - await prisma.storeAccount.create({ - - data: { storeId: store.id, phone: def.phone, name: def.name }, - - }); - - } - createdStores.push({ id: store.id, name: def.name }); } + // 一号两店:主账号 13910000001 绑定老城店 + 美食城店,便于测选店 + const multiStorePrimary = await prisma.storeAccount.create({ + data: { + phone: '13910000001', + name: '郑州老城店主', + isPrimary: 1, + bankAccountName: '郑州老城店主', + bankAccountNo: '6222029876543210', + bankBranch: '建设银行郑州分行', + bindings: { + create: createdStores.map((s) => ({ storeId: s.id })), + }, + }, + }); + + // 子账号样例:仅绑定第一家店 + await prisma.storeAccount.create({ + data: { + phone: '13910000011', + name: '老城店收银员', + isPrimary: 0, + parentAccountId: multiStorePrimary.id, + staffRole: 'CASHIER', + permissions: ['redeem', 'records'], + status: 'ACTIVE', + bindings: { + create: [{ storeId: createdStores[0].id }], + }, + }, + }); + const weekStart = (() => { @@ -511,12 +526,6 @@ async function main() { closeTime: '22:00', - bankAccountName: '本周新签体验店', - - bankAccountNo: '6222029876543211', - - bankBranch: '农业银行郑州分行', - createdAt: new Date(weekStart.getTime() + 2 * 24 * 60 * 60 * 1000), }, @@ -525,6 +534,18 @@ async function main() { createdStores.push({ id: newWeekStore.id, name: newWeekStore.name }); + await prisma.storeAccount.create({ + data: { + phone: '13910000003', + name: '本周新签体验店', + isPrimary: 1, + bankAccountName: '本周新签体验店', + bankAccountNo: '6222029876543211', + bankBranch: '农业银行郑州分行', + bindings: { create: [{ storeId: newWeekStore.id }] }, + }, + }); + await prisma.user.create({ diff --git a/server/dukang-api/src/common/guards/jwt-auth.guard.ts b/server/dukang-api/src/common/guards/jwt-auth.guard.ts index f9d1e6f..989db9d 100644 --- a/server/dukang-api/src/common/guards/jwt-auth.guard.ts +++ b/server/dukang-api/src/common/guards/jwt-auth.guard.ts @@ -13,6 +13,8 @@ export interface AuthUser { clientApp: ClientApp; sub: string; phoneVerified: boolean; + /** Selected store after POST /shop/auth/select-store */ + storeId?: bigint; } @Injectable() @@ -41,6 +43,9 @@ export class JwtAuthGuard implements CanActivate { clientApp, sub: payload.sub, phoneVerified: !!payload.phoneVerified, + ...(payload.storeId != null && payload.storeId !== '' + ? { storeId: BigInt(payload.storeId) } + : {}), } satisfies AuthUser; return true; } catch (err) { diff --git a/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts b/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts index ddb335a..1eb5103 100644 --- a/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts +++ b/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts @@ -29,6 +29,9 @@ export class OptionalJwtAuthGuard implements CanActivate { clientApp, sub: payload.sub, phoneVerified: !!payload.phoneVerified, + ...(payload.storeId != null && payload.storeId !== '' + ? { storeId: BigInt(payload.storeId) } + : {}), } satisfies AuthUser; } catch { /* ignore invalid token */ diff --git a/server/dukang-api/src/common/guards/shop-store.guard.ts b/server/dukang-api/src/common/guards/shop-store.guard.ts new file mode 100644 index 0000000..b245e27 --- /dev/null +++ b/server/dukang-api/src/common/guards/shop-store.guard.ts @@ -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; + } +} diff --git a/server/dukang-api/src/common/guards/store-membership.service.ts b/server/dukang-api/src/common/guards/store-membership.service.ts new file mode 100644 index 0000000..23be6b7 --- /dev/null +++ b/server/dukang-api/src/common/guards/store-membership.service.ts @@ -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; + } +} diff --git a/server/dukang-api/src/modules/analytics/analytics.service.ts b/server/dukang-api/src/modules/analytics/analytics.service.ts index aa2bcf8..052fe27 100644 --- a/server/dukang-api/src/modules/analytics/analytics.service.ts +++ b/server/dukang-api/src/modules/analytics/analytics.service.ts @@ -13,7 +13,7 @@ export type TrackEventInput = { export type TrackStoreEventInput = TrackEventInput & { storeAccountId?: bigint; - storeId: bigint; + storeId?: bigint; }; export type TrackPartnerEventInput = TrackEventInput & { @@ -54,12 +54,14 @@ export class AnalyticsService { } async trackStoreOne(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) { + if (event.storeId == null) return; await this.prisma.logStoreAnalytics.create({ data: this.toStoreRow(storeAccountId, clientApp, event), }); } trackStoreOneSafe(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) { + if (event.storeId == null) return; void this.trackStoreOne(storeAccountId, clientApp, event).catch(() => {}); } @@ -100,7 +102,7 @@ export class AnalyticsService { ) { return { storeAccountId, - storeId: event.storeId, + storeId: event.storeId!, eventName: event.eventName, clientApp: clientApp as ClientApp, refType: event.refType, diff --git a/server/dukang-api/src/modules/iam/auth.controller.ts b/server/dukang-api/src/modules/iam/auth.controller.ts index 29856b7..97a6385 100644 --- a/server/dukang-api/src/modules/iam/auth.controller.ts +++ b/server/dukang-api/src/modules/iam/auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; import { AuthService } from './auth.service'; import { @@ -122,6 +122,7 @@ export class ShopAuthController { dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5', + user.storeId, ); } return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5'); @@ -132,10 +133,23 @@ export class ShopAuthController { return this.authService.refreshAccessToken(dto.refreshToken, ClientApp.SHOP_H5); } + @Get('stores') + @UseGuards(JwtAuthGuard) + stores(@CurrentUser() user: AuthUser) { + return this.authService.listShopStores(user.actorId); + } + + @Post('select-store') + @UseGuards(JwtAuthGuard) + selectStore(@CurrentUser() user: AuthUser, @Body() body: { storeId: string }) { + if (!body?.storeId) throw new BadRequestException('请选择门店'); + return this.authService.selectShopStore(user.actorId, BigInt(body.storeId), ClientApp.SHOP_H5); + } + @Get('me') @UseGuards(JwtAuthGuard) me(@CurrentUser() user: AuthUser) { - return this.authService.getMe(user.actorType, user.actorId); + return this.authService.getShopMe(user); } } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 83d218f..a62dfae 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -172,12 +172,13 @@ export class AuthService { private trackStoreEvent( storeAccountId: bigint | undefined, - storeId: bigint, + storeId: bigint | undefined, clientApp: ClientApp | string, eventName: string, extraJson?: Record, ref?: { refType?: string; refId?: bigint }, ) { + if (storeId == null) return; this.analyticsService.trackStoreOneSafe(storeAccountId, clientApp, { storeId, eventName, @@ -393,14 +394,23 @@ export class AuthService { if (scene === SmsScene.STORE_LOGIN && actorRef?.refType === 'STORE') { const storeAccount = await this.prisma.storeAccount.findUnique({ where: { id: actorRef.refId }, - select: { id: true, storeId: true }, + select: { + id: true, + bindings: { select: { storeId: true }, take: 1 }, + }, }); if (storeAccount) { - this.trackStoreEvent(storeAccount.id, storeAccount.storeId, clientApp, 'store_sms_send', { - scene, - phone: this.maskPhone(normalizedPhone), - status: 'success', - }); + this.trackStoreEvent( + storeAccount.id, + storeAccount.bindings[0]?.storeId, + clientApp, + 'store_sms_send', + { + scene, + phone: this.maskPhone(normalizedPhone), + status: 'success', + }, + ); } } if ( @@ -487,7 +497,11 @@ export class AuthService { return this.buildSessionResponse(user, clientApp, user.deviceKey); } if (payload.actorType === 'STORE' && clientApp === ClientApp.SHOP_H5) { - return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp); + const storeId = + payload.storeId != null && payload.storeId !== '' + ? BigInt(payload.storeId) + : undefined; + return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp, storeId); } if (payload.actorType === 'PARTNER' && clientApp === ClientApp.PARTNER_H5) { return this.buildPartnerSessionResponse(BigInt(payload.actorId), clientApp); @@ -499,21 +513,188 @@ export class AuthService { } } - private async buildStoreSessionResponse(accountId: bigint, clientApp: ClientApp) { - const account = await this.prisma.storeAccount.findUnique({ + private async loadStoreAccountWithBindings(accountId: bigint) { + return this.prisma.storeAccount.findUnique({ where: { id: accountId }, - include: { store: true }, + include: { + bindings: { + include: { + store: { + select: { + id: true, + name: true, + status: true, + district: true, + address: true, + }, + }, + }, + orderBy: { createdAt: 'asc' }, + }, + }, }); + } + + private mapShopStoreOptions( + bindings: Array<{ + store: { + id: bigint; + name: string; + status: string; + district: string; + address: string; + }; + }>, + ) { + return bindings.map((b) => ({ + storeId: b.store.id.toString(), + name: b.store.name, + status: b.store.status, + district: b.store.district, + address: b.store.address, + })); + } + + private formatShopAccountMe(account: { + id: bigint; + name: string; + phone: string; + isPrimary: number; + staffRole: string | null; + permissions: unknown; + parentAccountId: bigint | null; + wxOpenId: string | null; + }) { + const permissions = Array.isArray(account.permissions) + ? (account.permissions as string[]) + : undefined; + return { + id: account.id.toString(), + name: account.name, + phone: account.phone, + isPrimary: account.isPrimary === 1, + staffRole: account.staffRole, + permissions, + primaryAccountId: account.parentAccountId?.toString(), + hasWechat: !!account.wxOpenId, + }; + } + + private async buildStoreSessionResponse( + accountId: bigint, + clientApp: ClientApp, + preferredStoreId?: bigint, + ) { + const account = await this.loadStoreAccountWithBindings(accountId); if (!account || account.status !== 'ACTIVE') { throw new UnauthorizedException('Invalid refresh token'); } - return this.issueToken('STORE', account.id, clientApp, false, undefined, { + return this.issueStoreSession(account, clientApp, preferredStoreId); + } + + private async issueStoreSession( + account: NonNullable>>, + clientApp: ClientApp, + preferredStoreId?: bigint, + options?: { autoSelectSingle?: boolean }, + ) { + const stores = this.mapShopStoreOptions(account.bindings); + const autoSelect = options?.autoSelectSingle !== false; + let selectedStoreId = preferredStoreId; + if (selectedStoreId != null) { + const ok = account.bindings.some((b) => b.store.id === selectedStoreId); + if (!ok) throw new ForbiddenException('无权访问该门店'); + } else if (autoSelect && stores.length === 1) { + selectedStoreId = BigInt(stores[0].storeId); + } + + const selected = selectedStoreId + ? account.bindings.find((b) => b.store.id === selectedStoreId)?.store + : undefined; + const accountMe = this.formatShopAccountMe(account); + const storePayload = { id: account.id.toString(), - storeId: account.storeId.toString(), + storeId: selected?.id.toString() ?? '', name: account.name, phone: account.phone, - storeName: account.store.name, - }); + storeName: selected?.name ?? '', + isPrimary: account.isPrimary === 1, + stores, + }; + + return this.issueToken( + 'STORE', + account.id, + clientApp, + false, + undefined, + storePayload, + undefined, + undefined, + undefined, + selectedStoreId, + { + account: accountMe, + stores, + store: selected + ? { + storeId: selected.id.toString(), + name: selected.name, + status: selected.status, + district: selected.district, + address: selected.address, + } + : null, + selectedStoreId: selectedStoreId?.toString(), + }, + ); + } + + async listShopStores(accountId: bigint) { + const account = await this.loadStoreAccountWithBindings(accountId); + if (!account || account.status !== 'ACTIVE') { + throw new UnauthorizedException('门店账号无效'); + } + return this.mapShopStoreOptions(account.bindings); + } + + async selectShopStore(accountId: bigint, storeId: bigint, clientApp: ClientApp) { + const account = await this.loadStoreAccountWithBindings(accountId); + if (!account || account.status !== 'ACTIVE') { + throw new UnauthorizedException('门店账号无效'); + } + const binding = account.bindings.find((b) => b.store.id === storeId); + if (!binding) throw new ForbiddenException('无权访问该门店'); + this.trackStoreEvent(account.id, storeId, clientApp, 'store_select'); + return this.issueStoreSession(account, clientApp, storeId, { autoSelectSingle: false }); + } + + async getShopMe(user: { actorId: bigint; storeId?: bigint }) { + const account = await this.loadStoreAccountWithBindings(user.actorId); + if (!account) throw new NotFoundException('门店账号不存在'); + const stores = this.mapShopStoreOptions(account.bindings); + const selected = user.storeId + ? account.bindings.find((b) => b.store.id === user.storeId)?.store + : undefined; + const accountMe = this.formatShopAccountMe(account); + return { + account: accountMe, + stores, + store: selected + ? { + storeId: selected.id.toString(), + name: selected.name, + status: selected.status, + district: selected.district, + address: selected.address, + } + : null, + // legacy flat fields for older clients + id: account.id.toString(), + storeId: selected?.id.toString() ?? '', + name: account.name, + phone: account.phone, + }; } private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) { @@ -656,38 +837,42 @@ export class AuthService { try { await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN); } catch (err) { - const account = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone } }); + const account = await this.prisma.storeAccount.findUnique({ + where: { phone: normalizedPhone }, + include: { bindings: { select: { storeId: true }, take: 1 } }, + }); if (account) { - this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_sms_verify_fail', { - phone: this.maskPhone(normalizedPhone), - reason: err instanceof BadRequestException ? err.message : '验证码错误', - }); + this.trackStoreEvent( + account.id, + account.bindings[0]?.storeId, + clientApp, + 'store_sms_verify_fail', + { + phone: this.maskPhone(normalizedPhone), + reason: err instanceof BadRequestException ? err.message : '验证码错误', + }, + ); } throw err; } - const account = await this.prisma.storeAccount.findUnique({ - where: { phone: normalizedPhone }, - include: { store: true }, - }); + const found = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone } }); + if (!found) throw new BadRequestException('该手机号未绑定门店'); + const account = await this.loadStoreAccountWithBindings(found.id); if (!account) throw new BadRequestException('该手机号未绑定门店'); if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用'); + if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店'); await this.prisma.storeAccount.update({ where: { id: account.id }, data: { lastLoginAt: new Date() }, }); - this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_sms_login', { + const firstStoreId = account.bindings[0]?.store.id; + this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_sms_login', { phone: this.maskPhone(normalizedPhone), }); - this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_login_success', { + this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_login_success', { method: 'sms', }); - return this.issueToken('STORE', account.id, clientApp, false, undefined, { - id: account.id.toString(), - storeId: account.storeId.toString(), - name: account.name, - phone: account.phone, - storeName: account.store.name, - }); + return this.issueStoreSession(account, clientApp); } async loginPartner(phone: string, code: string, clientApp: ClientApp) { @@ -814,11 +999,7 @@ export class AuthService { return this.formatUserProfile(user); } if (actorType === 'STORE') { - const account = await this.prisma.storeAccount.findUnique({ - where: { id: actorId }, - include: { store: true }, - }); - return serializeBigInt(account); + return this.getShopMe({ actorId }); } if (actorType === 'PARTNER') { const account = await this.prisma.partnerAccount.findUnique({ @@ -1104,7 +1285,6 @@ export class AuthService { let account = await this.prisma.storeAccount.findFirst({ where: { wxOpenId: session.openId }, - include: { store: true }, }); if (!account) { @@ -1118,22 +1298,21 @@ export class AuthService { wxUnionId: session.unionId ?? account.wxUnionId, lastLoginAt: new Date(), }, - include: { store: true }, }); - this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_wechat_login', { platform }); - this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_login_success', { + const full = await this.loadStoreAccountWithBindings(account.id); + if (!full || !full.bindings.length) { + throw new BadRequestException('该账号未绑定任何门店'); + } + + const firstStoreId = full.bindings[0]?.store.id; + this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_wechat_login', { platform }); + this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_login_success', { method: 'wechat', platform, }); - return this.issueToken('STORE', account.id, clientApp, false, undefined, { - id: account.id.toString(), - storeId: account.storeId.toString(), - name: account.name, - phone: account.phone, - storeName: account.store.name, - }); + return this.issueStoreSession(full, clientApp); } async bindStoreWechat( @@ -1141,6 +1320,7 @@ export class AuthService { code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5', + currentStoreId?: bigint, ) { this.assertWechatEnabled(); const session = @@ -1150,7 +1330,6 @@ export class AuthService { const account = await this.prisma.storeAccount.findUnique({ where: { id: storeAccountId }, - include: { store: true }, }); if (!account) throw new BadRequestException('门店账号不存在'); @@ -1161,25 +1340,27 @@ export class AuthService { throw new BadRequestException('该微信已绑定其他门店账号'); } - const updated = await this.prisma.storeAccount.update({ + await this.prisma.storeAccount.update({ where: { id: storeAccountId }, data: { wxOpenId: session.openId, wxUnionId: session.unionId ?? account.wxUnionId, lastLoginAt: new Date(), }, - include: { store: true }, }); - this.trackStoreEvent(updated.id, updated.storeId, clientApp, 'store_wechat_bind', { platform }); + const full = await this.loadStoreAccountWithBindings(storeAccountId); + if (!full) throw new BadRequestException('门店账号不存在'); - return this.issueToken('STORE', updated.id, clientApp, false, undefined, { - id: updated.id.toString(), - storeId: updated.storeId.toString(), - name: updated.name, - phone: updated.phone, - storeName: updated.store.name, - }); + this.trackStoreEvent( + storeAccountId, + currentStoreId ?? full.bindings[0]?.store.id, + clientApp, + 'store_wechat_bind', + { platform }, + ); + + return this.issueStoreSession(full, clientApp, currentStoreId); } async bindPartnerWechat( @@ -1497,6 +1678,8 @@ export class AuthService { partner?: Record, deviceKey?: string | null, hq?: Record, + storeId?: bigint, + shopExtra?: Record, ) { const payload = { sub: actorId.toString(), @@ -1504,6 +1687,7 @@ export class AuthService { actorId: actorId.toString(), clientApp, phoneVerified, + ...(storeId != null ? { storeId: storeId.toString() } : {}), }; const accessToken = this.jwtService.sign(payload); const refreshExpiresIn = actorType === 'STORE' || actorType === 'PARTNER' ? '7d' : '30d'; @@ -1519,6 +1703,7 @@ export class AuthService { store, partner, hq, + ...shopExtra, }; } } diff --git a/server/dukang-api/src/modules/iam/dto/store-staff.dto.ts b/server/dukang-api/src/modules/iam/dto/store-staff.dto.ts new file mode 100644 index 0000000..822632b --- /dev/null +++ b/server/dukang-api/src/modules/iam/dto/store-staff.dto.ts @@ -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[]; +} diff --git a/server/dukang-api/src/modules/iam/iam.module.ts b/server/dukang-api/src/modules/iam/iam.module.ts index 009d072..8702871 100644 --- a/server/dukang-api/src/modules/iam/iam.module.ts +++ b/server/dukang-api/src/modules/iam/iam.module.ts @@ -13,12 +13,16 @@ import { UserAddressController } from './user-address.controller'; import { UserAddressService } from './user-address.service'; import { PartnerStaffController } from './partner-staff.controller'; import { PartnerStaffService } from './partner-staff.service'; +import { StoreStaffController } from './store-staff.controller'; +import { StoreStaffService } from './store-staff.service'; import { AdminAuthController } from './admin-auth.controller'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard'; import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; +import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; +import { StoreMembershipService } from '../../common/guards/store-membership.service'; @Module({ imports: [ @@ -34,11 +38,37 @@ import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; ShopAuthController, PartnerAuthController, PartnerStaffController, + StoreStaffController, UserProfileController, UserAddressController, AdminAuthController, ], - providers: [AuthService, UserAddressService, PartnerStaffService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard], - exports: [AuthService, UserAddressService, PartnerStaffService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard], + providers: [ + AuthService, + UserAddressService, + PartnerStaffService, + StoreStaffService, + StoreMembershipService, + JwtAuthGuard, + PhoneVerifiedGuard, + OptionalJwtAuthGuard, + HqAuthGuard, + PartnerPrimaryGuard, + ShopStoreGuard, + ], + exports: [ + AuthService, + UserAddressService, + PartnerStaffService, + StoreStaffService, + StoreMembershipService, + JwtModule, + JwtAuthGuard, + PhoneVerifiedGuard, + OptionalJwtAuthGuard, + HqAuthGuard, + PartnerPrimaryGuard, + ShopStoreGuard, + ], }) export class IamModule {} diff --git a/server/dukang-api/src/modules/iam/store-staff.controller.ts b/server/dukang-api/src/modules/iam/store-staff.controller.ts new file mode 100644 index 0000000..95d1ff7 --- /dev/null +++ b/server/dukang-api/src/modules/iam/store-staff.controller.ts @@ -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)); + } +} diff --git a/server/dukang-api/src/modules/iam/store-staff.service.ts b/server/dukang-api/src/modules/iam/store-staff.service.ts new file mode 100644 index 0000000..59038e5 --- /dev/null +++ b/server/dukang-api/src/modules/iam/store-staff.service.ts @@ -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 = {}; + 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, + ) { + 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)}`; + } +} diff --git a/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts b/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts index a3ff9ab..9d23811 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts @@ -54,16 +54,20 @@ export class AdminRedeemDebugService { throw new NotFoundException('用户不存在,请检查 ID、用户编号或手机号'); } - private async resolveStoreAccountId(storeId: string): Promise { - const account = await this.prisma.storeAccount.findFirst({ - where: { storeId: this.parseStoreId(storeId), status: 'ACTIVE' }, + private async resolveStoreAccountId(storeId: string): Promise<{ accountId: bigint; storeId: bigint }> { + const sid = this.parseStoreId(storeId); + const binding = await this.prisma.storeAccountStore.findFirst({ + where: { + storeId: sid, + storeAccount: { status: 'ACTIVE', isPrimary: 1 }, + }, orderBy: { id: 'asc' }, - select: { id: true, store: { select: { name: true } } }, + select: { storeAccountId: true, storeId: true }, }); - if (!account) { + if (!binding) { throw new NotFoundException('该门店无可用账户,请先创建门店账户'); } - return account.id; + return { accountId: binding.storeAccountId, storeId: binding.storeId }; } async createToken(dto: AdminRedeemDebugCreateTokenDto) { @@ -76,32 +80,32 @@ export class AdminRedeemDebugService { } async preview(dto: AdminRedeemDebugStoreTokenDto) { - const storeAccountId = await this.resolveStoreAccountId(dto.storeId); - return this.redeemService.previewRedeem(storeAccountId, dto.token); + const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId); + return this.redeemService.previewRedeem(accountId, storeId, dto.token); } async confirm(dto: AdminRedeemDebugStoreTokenDto) { - const storeAccountId = await this.resolveStoreAccountId(dto.storeId); - return this.redeemService.confirmRedeem(storeAccountId, { token: dto.token }); + const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId); + return this.redeemService.confirmRedeem(accountId, storeId, { token: dto.token }); } async sendPhoneLookupSms(dto: AdminRedeemDebugPhoneStoreDto) { - const storeAccountId = await this.resolveStoreAccountId(dto.storeId); - return this.redeemService.sendPhoneLookupSms(storeAccountId, dto.phone); + const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId); + return this.redeemService.sendPhoneLookupSms(accountId, storeId, dto.phone); } async phoneBalance(dto: AdminRedeemDebugPhoneBalanceDto) { - const storeAccountId = await this.resolveStoreAccountId(dto.storeId); - return this.redeemService.verifyPhoneAndGetBalance(storeAccountId, dto.phone, dto.code); + const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId); + return this.redeemService.verifyPhoneAndGetBalance(accountId, storeId, dto.phone, dto.code); } async phonePrepare(dto: AdminRedeemDebugPhonePrepareDto) { - const storeAccountId = await this.resolveStoreAccountId(dto.storeId); - return this.redeemService.preparePhoneRedeem(storeAccountId, dto.sessionId, dto.amount); + const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId); + return this.redeemService.preparePhoneRedeem(accountId, storeId, dto.sessionId, dto.amount); } async phoneConfirm(dto: AdminRedeemDebugPhoneConfirmDto) { - const storeAccountId = await this.resolveStoreAccountId(dto.storeId); - return this.redeemService.confirmPhoneRedeem(storeAccountId, dto.sessionId, dto.code); + const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId); + return this.redeemService.confirmPhoneRedeem(accountId, storeId, dto.sessionId, dto.code); } } diff --git a/server/dukang-api/src/modules/ops/admin-store-logs.service.ts b/server/dukang-api/src/modules/ops/admin-store-logs.service.ts index 94c0f69..ebb8702 100644 --- a/server/dukang-api/src/modules/ops/admin-store-logs.service.ts +++ b/server/dukang-api/src/modules/ops/admin-store-logs.service.ts @@ -131,11 +131,13 @@ export class AdminStoreLogsService { if (query.phone) accountWhere.phone = { contains: query.phone }; const accounts = await this.prisma.storeAccount.findMany({ where: accountWhere, - select: { storeId: true }, + select: { + bindings: { select: { storeId: true } }, + }, take: 100, }); if (accounts.length === 0) return []; - const ids = [...new Set(accounts.map((a) => a.storeId))]; + const ids = [...new Set(accounts.flatMap((a) => a.bindings.map((b) => b.storeId)))]; if (storeWhere.name) { const stores = await this.prisma.store.findMany({ where: { id: { in: ids }, ...storeWhere }, diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index e923e51..c0e8dab 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -41,14 +41,26 @@ export class AdminStoresService { include: { cityRef: { select: { id: true, name: true, code: true } }, partnerAccount: { select: { id: true, companyName: true } }, - account: { select: { id: true, phone: true, name: true, status: true } }, + bindings: { + where: { storeAccount: { isPrimary: 1 } }, + take: 1, + include: { + storeAccount: { select: { id: true, phone: true, name: true, status: true } }, + }, + }, coverResource: { select: { id: true, url: true } }, }, }), this.prisma.store.count({ where }), ]); return serializeBigInt({ - items: items.map((s) => mapStoreCompat(s)), + items: items.map((s) => + mapStoreCompat({ + ...s, + account: s.bindings[0]?.storeAccount ?? null, + bindings: undefined, + }), + ), total, page, pageSize, @@ -62,7 +74,11 @@ export class AdminStoresService { cityRef: true, partnerAccount: true, category: true, - account: true, + bindings: { + where: { storeAccount: { isPrimary: 1 } }, + take: 1, + include: { storeAccount: true }, + }, coverResource: true, _count: { select: { redeemRecords: true, ratings: true } }, }, @@ -81,6 +97,8 @@ export class AdminStoresService { ]); return serializeBigInt(mapStoreCompat({ ...store, + account: store.bindings[0]?.storeAccount ?? null, + bindings: undefined, media, audits, redeemCount: store._count.redeemRecords, @@ -165,7 +183,12 @@ export class AdminStoresService { const existingAccount = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone }, }); - if (existingAccount) throw new BadRequestException('该手机号已绑定门店'); + if (existingAccount && existingAccount.isPrimary !== 1) { + throw new BadRequestException('该手机号已是门店子账号'); + } + if (existingAccount && existingAccount.status !== 'ACTIVE') { + throw new BadRequestException('该手机号对应门店账号已停用'); + } const partnerAccountId = BigInt(dto.partnerAccountId); const partnerAccount = await this.prisma.partnerAccount.findUnique({ @@ -191,9 +214,6 @@ export class AdminStoresService { district: dto.district ?? '', address: dto.address, intro: dto.intro ?? null, - bankAccountName: dto.bankAccountName ?? null, - bankAccountNo: dto.bankAccountNo ?? null, - bankBranch: dto.bankBranch ?? null, openTime: '10:00', closeTime: '22:00', status: 'OPEN', @@ -258,13 +278,37 @@ export class AdminStoresService { }, }); - await this.prisma.storeAccount.create({ - data: { - storeId: store.id, - phone: normalizedPhone, - name: dto.accountName ?? dto.name, - }, - }); + const bankAccountName = dto.bankAccountName ?? null; + const bankAccountNo = dto.bankAccountNo ?? null; + const bankBranch = dto.bankBranch ?? null; + + if (existingAccount) { + await this.prisma.storeAccountStore.create({ + data: { storeAccountId: existingAccount.id, storeId: store.id }, + }); + if (bankAccountName || bankAccountNo || bankBranch) { + await this.prisma.storeAccount.update({ + where: { id: existingAccount.id }, + data: { + ...(bankAccountName != null ? { bankAccountName } : {}), + ...(bankAccountNo != null ? { bankAccountNo } : {}), + ...(bankBranch != null ? { bankBranch } : {}), + }, + }); + } + } else { + await this.prisma.storeAccount.create({ + data: { + phone: normalizedPhone, + name: dto.accountName ?? dto.name, + isPrimary: 1, + bankAccountName, + bankAccountNo, + bankBranch, + bindings: { create: [{ storeId: store.id }] }, + }, + }); + } return this.detailStore(store.id); } @@ -272,12 +316,37 @@ export class AdminStoresService { async createStoreAccount(dto: CreateStoreAccountDto) { const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) }, - include: { account: true }, + include: { bindings: true }, }); if (!store) throw new BadRequestException('门店不存在'); - if (store.account) throw new BadRequestException('门店已有账户'); + const primaryBound = store.bindings.length > 0 + ? await this.prisma.storeAccount.findFirst({ + where: { + isPrimary: 1, + bindings: { some: { storeId: store.id } }, + }, + }) + : null; + if (primaryBound) throw new BadRequestException('门店已有主账号绑定'); + + const existing = await this.prisma.storeAccount.findUnique({ where: { phone: dto.phone } }); + if (existing) { + if (existing.isPrimary !== 1) { + throw new BadRequestException('该手机号已是门店子账号'); + } + await this.prisma.storeAccountStore.create({ + data: { storeAccountId: existing.id, storeId: store.id }, + }); + return serializeBigInt(existing); + } + const account = await this.prisma.storeAccount.create({ - data: { storeId: store.id, phone: dto.phone, name: dto.name }, + data: { + phone: dto.phone, + name: dto.name, + isPrimary: 1, + bindings: { create: [{ storeId: store.id }] }, + }, }); return serializeBigInt(account); } @@ -345,9 +414,11 @@ export class AdminStoresService { async listStoreAccounts(query: AdminStoreAccountsQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; - const where: Prisma.StoreAccountWhereInput = {}; + const where: Prisma.StoreAccountWhereInput = { isPrimary: 1 }; if (query.phone) where.phone = { contains: query.phone }; - if (query.storeId) where.storeId = BigInt(query.storeId); + if (query.storeId) { + where.bindings = { some: { storeId: BigInt(query.storeId) } }; + } if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals']; const [items, total] = await Promise.all([ @@ -357,21 +428,53 @@ export class AdminStoresService { skip: (page - 1) * pageSize, take: pageSize, include: { - store: { select: { id: true, name: true, status: true, cityName: true } }, + bindings: { + include: { + store: { select: { id: true, name: true, status: true, cityName: true } }, + }, + }, + _count: { select: { childAccounts: true, bindings: true } }, }, }), this.prisma.storeAccount.count({ where }), ]); - return serializeBigInt({ items, total, page, pageSize }); + const mapped = items.map((row) => ({ + ...row, + storeCount: row._count.bindings, + staffCount: row._count.childAccounts, + stores: row.bindings.map((b) => b.store), + store: row.bindings[0]?.store ?? null, + })); + return serializeBigInt({ items: mapped, total, page, pageSize }); } async detailStoreAccount(id: bigint) { const account = await this.prisma.storeAccount.findUnique({ where: { id }, - include: { store: { include: { cityRef: true, partnerAccount: true } } }, + include: { + bindings: { + include: { + store: { include: { cityRef: true, partnerAccount: true } }, + }, + }, + childAccounts: { + include: { + bindings: { + include: { + store: { select: { id: true, name: true, status: true } }, + }, + }, + }, + }, + }, }); if (!account) throw new NotFoundException('门店账号不存在'); - return serializeBigInt(account); + return serializeBigInt({ + ...account, + stores: account.bindings.map((b) => b.store), + store: account.bindings[0]?.store ?? null, + staff: account.childAccounts, + }); } async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) { diff --git a/server/dukang-api/src/modules/ops/admin-wechat-bindings.service.ts b/server/dukang-api/src/modules/ops/admin-wechat-bindings.service.ts index f1fe060..dcfb122 100644 --- a/server/dukang-api/src/modules/ops/admin-wechat-bindings.service.ts +++ b/server/dukang-api/src/modules/ops/admin-wechat-bindings.service.ts @@ -185,11 +185,17 @@ export class AdminWechatBindingsService { const accounts = await this.prisma.storeAccount.findMany({ where, - include: { store: { select: { id: true, name: true } } }, + include: { + bindings: { + take: 1, + include: { store: { select: { id: true, name: true } } }, + }, + }, }); for (const a of accounts) { if (!a.wxOpenId) continue; + const firstStore = a.bindings[0]?.store; rows.push({ actorType: 'STORE', actorId: a.id, @@ -197,8 +203,8 @@ export class AdminWechatBindingsService { name: a.name, wxOpenId: a.wxOpenId, wxUnionId: a.wxUnionId, - refId: a.storeId, - refLabel: a.store.name, + refId: firstStore?.id, + refLabel: firstStore?.name ?? a.name, lastLoginAt: a.lastLoginAt, status: a.status, }); diff --git a/server/dukang-api/src/modules/redeem/redeem.controller.ts b/server/dukang-api/src/modules/redeem/redeem.controller.ts index f80c754..0711859 100644 --- a/server/dukang-api/src/modules/redeem/redeem.controller.ts +++ b/server/dukang-api/src/modules/redeem/redeem.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { RedeemService } from './redeem.service'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; +import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { RedeemPhoneBalanceDto, @@ -37,18 +38,18 @@ export class UserRedeemController { } @Controller('shop/redeem') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, ShopStoreGuard) export class ShopRedeemController { constructor(private readonly redeemService: RedeemService) {} @Post('preview') preview(@CurrentUser() user: AuthUser, @Body() body: { token: string }) { - return this.redeemService.previewRedeem(user.actorId, body.token); + return this.redeemService.previewRedeem(user.actorId, user.storeId!, body.token); } @Post('confirm') confirm(@CurrentUser() user: AuthUser, @Body() body: { token: string }) { - return this.redeemService.confirmRedeem(user.actorId, body); + return this.redeemService.confirmRedeem(user.actorId, user.storeId!, body); } @Post('failures') @@ -56,7 +57,7 @@ export class ShopRedeemController { @CurrentUser() user: AuthUser, @Body() body: RedeemFailureReportDto, ) { - return this.redeemService.reportNetworkFailure(user.actorId, body); + return this.redeemService.reportNetworkFailure(user.actorId, user.storeId!, body); } @Post('pending') @@ -64,7 +65,7 @@ export class ShopRedeemController { @CurrentUser() user: AuthUser, @Body() body: RedeemPendingSubmitDto, ) { - return this.redeemService.submitPendingRedeem(user.actorId, body); + return this.redeemService.submitPendingRedeem(user.actorId, user.storeId!, body); } @Get('records') @@ -73,26 +74,46 @@ export class ShopRedeemController { @Query('page') page = '1', @Query('pageSize') pageSize = '20', ) { - return this.redeemService.listShopRecords(user.actorId, Number(page), Number(pageSize)); + return this.redeemService.listShopRecords( + user.actorId, + user.storeId!, + Number(page), + Number(pageSize), + ); } @Post('phone/send-lookup-sms') sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) { - return this.redeemService.sendPhoneLookupSms(user.actorId, body.phone); + return this.redeemService.sendPhoneLookupSms(user.actorId, user.storeId!, body.phone); } @Post('phone/balance') phoneBalance(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneBalanceDto) { - return this.redeemService.verifyPhoneAndGetBalance(user.actorId, body.phone, body.code); + return this.redeemService.verifyPhoneAndGetBalance( + user.actorId, + user.storeId!, + body.phone, + body.code, + ); } @Post('phone/prepare') phonePrepare(@CurrentUser() user: AuthUser, @Body() body: RedeemPhonePrepareDto) { - return this.redeemService.preparePhoneRedeem(user.actorId, body.sessionId, body.amount); + return this.redeemService.preparePhoneRedeem( + user.actorId, + user.storeId!, + body.sessionId, + body.amount, + ); } @Post('phone/confirm') phoneConfirm(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneConfirmDto) { - return this.redeemService.confirmPhoneRedeem(user.actorId, body.sessionId, body.code); + return this.redeemService.confirmPhoneRedeem( + user.actorId, + user.storeId!, + body.sessionId, + body.code, + ); } } diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index 497a275..b6ba864 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -89,15 +89,28 @@ export class RedeemService { return `redeem:phone-session:${sessionId}`; } - private async loadOpenStoreAccount(storeAccountId: bigint) { - const account = await this.prisma.storeAccount.findUniqueOrThrow({ - where: { id: storeAccountId }, - include: { store: true }, + private async loadOpenStoreAccount(storeAccountId: bigint, storeId: bigint) { + const binding = await this.prisma.storeAccountStore.findUnique({ + where: { + storeAccountId_storeId: { storeAccountId, storeId }, + }, + include: { + storeAccount: true, + store: true, + }, }); - if (account.store.status !== 'OPEN') { + if (!binding) throw new BadRequestException('无权访问该门店'); + if (binding.storeAccount.status !== 'ACTIVE') { + throw new BadRequestException('门店账号已停用'); + } + if (binding.store.status !== 'OPEN') { throw new BadRequestException('门店未营业'); } - return account; + return { + ...binding.storeAccount, + storeId: binding.store.id, + store: binding.store, + }; } private async resolveUserByPhone(phone: string) { @@ -228,8 +241,8 @@ export class RedeemService { return record; } - async sendPhoneLookupSms(storeAccountId: bigint, phone: string) { - const account = await this.loadOpenStoreAccount(storeAccountId); + async sendPhoneLookupSms(storeAccountId: bigint, storeId: bigint, phone: string) { + const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const normalizedPhone = this.normalizeMobilePhone(phone); await this.resolveUserByPhone(normalizedPhone); await this.authService.sendSms(normalizedPhone, SmsScene.REDEEM_PHONE_LOOKUP, { @@ -243,8 +256,8 @@ export class RedeemService { return { ok: true, maskedPhone: this.maskPhoneForStore(normalizedPhone) }; } - async verifyPhoneAndGetBalance(storeAccountId: bigint, phone: string, code: string) { - const account = await this.loadOpenStoreAccount(storeAccountId); + async verifyPhoneAndGetBalance(storeAccountId: bigint, storeId: bigint, phone: string, code: string) { + const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const normalizedPhone = this.normalizeMobilePhone(phone); const user = await this.resolveUserByPhone(normalizedPhone); await this.authService.verifySmsCode(normalizedPhone, code, SmsScene.REDEEM_PHONE_LOOKUP); @@ -298,8 +311,8 @@ export class RedeemService { return session; } - async preparePhoneRedeem(storeAccountId: bigint, sessionId: string, amount: number) { - const account = await this.loadOpenStoreAccount(storeAccountId); + async preparePhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, amount: number) { + const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const session = await this.loadPhoneSession(sessionId, storeAccountId); const userId = BigInt(session.userId); const { allocations } = await this.computeDirectAllocations(userId, amount); @@ -337,8 +350,8 @@ export class RedeemService { }; } - async confirmPhoneRedeem(storeAccountId: bigint, sessionId: string, code: string) { - const account = await this.loadOpenStoreAccount(storeAccountId); + async confirmPhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, code: string) { + const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const session = await this.loadPhoneSession(sessionId, storeAccountId); if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) { throw new BadRequestException('请先选择核销金额并发送确认验证码'); @@ -462,8 +475,8 @@ export class RedeemService { return { status: 'EXPIRED' as const }; } - async previewRedeem(storeAccountId: bigint, token: string) { - const account = await this.loadOpenStoreAccount(storeAccountId); + async previewRedeem(storeAccountId: bigint, storeId: bigint, token: string) { + const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const cached = await this.redis.getJson(`redeem:token:${token}`); if (!cached) throw new BadRequestException('核销码无效或已过期'); @@ -516,8 +529,8 @@ export class RedeemService { }); } - async confirmRedeem(storeAccountId: bigint, body: { token: string }) { - const account = await this.loadOpenStoreAccount(storeAccountId); + async confirmRedeem(storeAccountId: bigint, storeId: bigint, body: { token: string }) { + const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const token = body.token?.trim(); if (!token) throw new BadRequestException('请提供核销码'); @@ -594,6 +607,7 @@ export class RedeemService { async reportNetworkFailure( storeAccountId: bigint, + storeId: bigint, body: { token: string; errorClass: 'NETWORK' | 'BUSINESS'; @@ -601,9 +615,7 @@ export class RedeemService { step: 'preview' | 'confirm'; }, ) { - const account = await this.prisma.storeAccount.findUniqueOrThrow({ - where: { id: storeAccountId }, - }); + await this.loadOpenStoreAccount(storeAccountId, storeId); const token = body.token.trim(); if (!token) throw new BadRequestException('请提供核销码'); @@ -618,7 +630,7 @@ export class RedeemService { const thresholdReached = failCount >= REDEEM_WEAKNET_FAIL_THRESHOLD; this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { - storeId: account.storeId, + storeId, eventName: 'store_redeem_confirm_fail', extraJson: { token, @@ -632,7 +644,7 @@ export class RedeemService { if (thresholdReached && body.errorClass === 'NETWORK') { this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { - storeId: account.storeId, + storeId, eventName: 'store_redeem_weaknet_threshold', extraJson: { token, @@ -670,9 +682,10 @@ export class RedeemService { async submitPendingRedeem( storeAccountId: bigint, + storeId: bigint, body: { token: string; photoResourceId: string; failCount?: number; remark?: string }, ) { - const account = await this.loadOpenStoreAccount(storeAccountId); + const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const token = body.token.trim(); if (!token) throw new BadRequestException('请提供核销码'); @@ -831,13 +844,7 @@ export class RedeemService { throw new BadRequestException('待处理单状态不可补核销'); } - const account = await this.prisma.storeAccount.findUniqueOrThrow({ - where: { id: pending.storeAccountId }, - include: { store: true }, - }); - if (account.store.status !== 'OPEN') { - throw new BadRequestException('门店未营业,无法补核销'); - } + const account = await this.loadOpenStoreAccount(pending.storeAccountId, pending.storeId); const allocationsRaw = pending.allocationsJson as Array<{ couponId: string; amount: number }>; const normalizedAllocations = allocationsRaw.map((item) => ({ @@ -941,42 +948,42 @@ export class RedeemService { return serializeBigInt(updated); } - async listShopRecords(storeAccountId: bigint, page = 1, pageSize = 20) { - const account = await this.prisma.storeAccount.findUniqueOrThrow({ - where: { id: storeAccountId }, + async listShopRecords(storeAccountId: bigint, storeId: bigint, page = 1, pageSize = 20) { + await this.prisma.storeAccountStore.findUniqueOrThrow({ + where: { storeAccountId_storeId: { storeAccountId, storeId } }, }); const [list, total] = await Promise.all([ this.prisma.redeemRecord.findMany({ - where: { storeId: account.storeId }, + where: { storeId }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { payout: true }, }), - this.prisma.redeemRecord.count({ where: { storeId: account.storeId } }), + this.prisma.redeemRecord.count({ where: { storeId } }), ]); return { list: serializeBigInt(list), total, page, pageSize }; } - async getShopDashboard(storeAccountId: bigint) { - const account = await this.prisma.storeAccount.findUniqueOrThrow({ - where: { id: storeAccountId }, + async getShopDashboard(storeAccountId: bigint, storeId: bigint) { + const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({ + where: { storeAccountId_storeId: { storeAccountId, storeId } }, include: { store: true }, }); const start = new Date(); start.setHours(0, 0, 0, 0); const records = await this.prisma.redeemRecord.findMany({ - where: { storeId: account.storeId, createdAt: { gte: start } }, + where: { storeId, createdAt: { gte: start } }, }); const todayCount = records.length; const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0); const recent = await this.prisma.redeemRecord.findMany({ - where: { storeId: account.storeId }, + where: { storeId }, orderBy: { createdAt: 'desc' }, take: 3, }); return serializeBigInt({ - store: account.store, + store: binding.store, todayCount, todayAmount, recentRecords: recent, diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 6cdf951..dd786e6 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co import { SettlementService } from './settlement.service'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; +import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; @@ -20,7 +21,7 @@ export class SettlementController { } @Controller('shop/payouts') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, ShopStoreGuard) export class ShopPayoutController { constructor(private readonly settlementService: SettlementService) {} @@ -30,7 +31,12 @@ export class ShopPayoutController { @Query('page') page = '1', @Query('pageSize') pageSize = '20', ) { - return this.settlementService.listShopPayouts(user.actorId, Number(page), Number(pageSize)); + return this.settlementService.listShopPayouts( + user.actorId, + user.storeId!, + Number(page), + Number(pageSize), + ); } } diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index 03e34e7..a5433c4 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -54,11 +54,11 @@ export class SettlementService { return serializeBigInt(payout); } - async listShopPayouts(storeAccountId: bigint, page = 1, pageSize = 20) { - const account = await this.prisma.storeAccount.findUniqueOrThrow({ - where: { id: storeAccountId }, + async listShopPayouts(storeAccountId: bigint, storeId: bigint, page = 1, pageSize = 20) { + await this.prisma.storeAccountStore.findUniqueOrThrow({ + where: { storeAccountId_storeId: { storeAccountId, storeId } }, }); - const where = { storeId: account.storeId }; + const where = { storeId }; const [items, total] = await Promise.all([ this.prisma.storePayout.findMany({ where, diff --git a/server/dukang-api/src/modules/store/store.controller.ts b/server/dukang-api/src/modules/store/store.controller.ts index 12de6b8..e53237b 100644 --- a/server/dukang-api/src/modules/store/store.controller.ts +++ b/server/dukang-api/src/modules/store/store.controller.ts @@ -3,6 +3,7 @@ import { StoreService } from './store.service'; import { RedeemService } from '../redeem/redeem.service'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; +import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; @Controller('stores') @@ -105,28 +106,28 @@ export class PartnerReportController { } @Controller('shop/store') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, ShopStoreGuard) export class ShopStoreController { constructor(private readonly storeService: StoreService) {} @Get() info(@CurrentUser() user: AuthUser) { - return this.storeService.getShopStore(user.actorId); + return this.storeService.getShopStore(user.actorId, user.storeId!); } @Put('status') status(@CurrentUser() user: AuthUser, @Body() body: { status: 'OPEN' | 'PAUSED' }) { - return this.storeService.updateShopStatus(user.actorId, body.status); + return this.storeService.updateShopStatus(user.actorId, user.storeId!, body.status); } } @Controller('shop/dashboard') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, ShopStoreGuard) export class ShopDashboardController { constructor(private readonly redeemService: RedeemService) {} @Get() async dashboard(@CurrentUser() user: AuthUser) { - return this.redeemService.getShopDashboard(user.actorId); + return this.redeemService.getShopDashboard(user.actorId, user.storeId!); } } diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index 8367726..6259fd8 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -116,17 +116,34 @@ export class StoreService { } const existingAccount = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone }, + include: { _count: { select: { bindings: true } } }, }); - if (existingAccount) { - return { available: false, message: '该手机号已绑定门店' }; + if (!existingAccount) { + return { available: true, existingStoreCount: 0, needConfirm: false }; } - return { available: true }; + if (existingAccount.isPrimary !== 1) { + return { available: false, message: '该手机号已是门店子账号,不可作为负责人' }; + } + if (existingAccount.status !== 'ACTIVE') { + return { available: false, message: '该手机号对应门店账号已停用' }; + } + const existingStoreCount = existingAccount._count.bindings; + return { + available: true, + existingStoreCount, + needConfirm: existingStoreCount > 0, + message: + existingStoreCount > 0 + ? `该手机号已是门店主账号(已绑 ${existingStoreCount} 家店),确认后将追加绑定新店` + : undefined, + }; } async createStore(partnerAccountId: bigint, body: Record) { const { primaryId } = await this.resolvePartnerScope(partnerAccountId); const normalizedPhone = String(body.phone).trim(); - await this.assertStorePhoneAvailable(normalizedPhone); + const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true'; + await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting); const city = await this.resolvePartnerCity(partnerAccountId, body.cityId); const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : ''; @@ -139,6 +156,10 @@ export class StoreService { if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片'); if (!contractUrl) throw new BadRequestException('请上传签约合同'); + const bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null; + const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null; + const bankBranch = body.bankBranch ? String(body.bankBranch) : null; + const store = await this.prisma.store.create({ data: { cityId: city.id, @@ -151,9 +172,6 @@ export class StoreService { district: String(body.district ?? ''), address: String(body.address), intro: body.intro ? String(body.intro) : null, - bankAccountName: body.bankAccountName ? String(body.bankAccountName) : null, - bankAccountNo: body.bankAccountNo ? String(body.bankAccountNo) : null, - bankBranch: body.bankBranch ? String(body.bankBranch) : null, openTime: body.openTime ? String(body.openTime) : '10:00', closeTime: body.closeTime ? String(body.closeTime) : '22:00', status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED', @@ -219,14 +237,38 @@ export class StoreService { extraJson: body as never, }, }); + void audit; - await this.prisma.storeAccount.create({ - data: { - storeId: store.id, - phone: normalizedPhone, - name: String(body.name), - }, + const existingPrimary = await this.prisma.storeAccount.findUnique({ + where: { phone: normalizedPhone }, }); + if (existingPrimary && existingPrimary.isPrimary === 1) { + await this.prisma.storeAccountStore.create({ + data: { storeAccountId: existingPrimary.id, storeId: store.id }, + }); + if (bankAccountName || bankAccountNo || bankBranch) { + await this.prisma.storeAccount.update({ + where: { id: existingPrimary.id }, + data: { + ...(bankAccountName != null ? { bankAccountName } : {}), + ...(bankAccountNo != null ? { bankAccountNo } : {}), + ...(bankBranch != null ? { bankBranch } : {}), + }, + }); + } + } else { + await this.prisma.storeAccount.create({ + data: { + phone: normalizedPhone, + name: String(body.name), + isPrimary: 1, + bankAccountName, + bankAccountNo, + bankBranch, + bindings: { create: [{ storeId: store.id }] }, + }, + }); + } this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primaryId, @@ -321,26 +363,26 @@ export class StoreService { return this.partnerGetStore(partnerAccountId, storeId); } - async getShopStore(storeAccountId: bigint) { - const account = await this.prisma.storeAccount.findUniqueOrThrow({ - where: { id: storeAccountId }, + async getShopStore(storeAccountId: bigint, storeId: bigint) { + const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({ + where: { storeAccountId_storeId: { storeAccountId, storeId } }, include: { store: { include: { category: true, coverResource: true } } }, }); - return serializeBigInt(mapStoreCompat(account.store)); + return serializeBigInt(mapStoreCompat(binding.store)); } - async updateShopStatus(storeAccountId: bigint, status: 'OPEN' | 'PAUSED') { - const account = await this.prisma.storeAccount.findUniqueOrThrow({ - where: { id: storeAccountId }, + async updateShopStatus(storeAccountId: bigint, storeId: bigint, status: 'OPEN' | 'PAUSED') { + const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({ + where: { storeAccountId_storeId: { storeAccountId, storeId } }, include: { store: true }, }); - const previousStatus = account.store.status; + const previousStatus = binding.store.status; const store = await this.prisma.store.update({ - where: { id: account.storeId }, + where: { id: storeId }, data: { status }, }); this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', { - storeId: account.storeId, + storeId, eventName: 'store_status_change', extraJson: { status, @@ -721,10 +763,13 @@ export class StoreService { } } - private async assertStorePhoneAvailable(phone: string) { + private async assertStorePhoneAvailable(phone: string, confirmBindExisting = false) { const result = await this.partnerCheckStorePhone(phone); if (!result.available) { - throw new BadRequestException(result.message ?? '该手机号已绑定门店'); + throw new BadRequestException(result.message ?? '该手机号不可用'); + } + if (result.needConfirm && !confirmBindExisting) { + throw new BadRequestException(result.message ?? '该手机号已绑定门店,请确认后重试'); } }