diff --git a/client/src/App.tsx b/client/src/App.tsx index cf43655..2dff414 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -36,6 +36,8 @@ import { MenuEngineeringPage } from '@/pages/MenuEngineeringPage' import { RegionComparisonPage } from '@/pages/RegionComparisonPage' import { EmployeePerformancePage } from '@/pages/EmployeePerformancePage' import { InventoryTurnoverPage } from '@/pages/InventoryTurnoverPage' +import { TenantManagementPage } from '@/pages/TenantManagementPage' +import { TenantUsersPage } from '@/pages/TenantUsersPage' const queryClient = new QueryClient({ defaultOptions: { @@ -78,7 +80,7 @@ export default function App() { ) : ( - } /> + : } /> } /> } /> } /> @@ -112,6 +114,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 35a248e..1691d04 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { ReactNode, useState } from 'react' import { Link, useLocation } from 'react-router-dom' -import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon } from 'lucide-react' +import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon, Building2 } from 'lucide-react' import { cn } from '@/lib/utils' import { StatusBanner } from '@/components/StatusBanner' @@ -84,11 +84,18 @@ const menuGroups: MenuGroup[] = [ { title: '系统管理', items: [ + { path: '/tenant-users', label: '用户管理', icon: Users, roles: ['hq'] }, { path: '/data-quality', label: '数据质量', icon: Database, roles: ['hq', 'dept'] }, { path: '/indicators', label: '指标字典', icon: Settings, roles: ['hq', 'dept', 'regional', 'store'] }, { path: '/ontology', label: '本体标准', icon: Receipt, roles: ['hq', 'dept'] }, ], }, + { + title: '平台管理', + items: [ + { path: '/tenant-management', label: '租户管理', icon: Building2, roles: ['platform_admin'] }, + ], + }, ] export function Layout({ children, user, onLogout }: LayoutProps) { @@ -166,9 +173,11 @@ export function Layout({ children, user, onLogout }: LayoutProps) { {/* Main content */}
-
- -
+ {user?.role !== 'platform_admin' && ( +
+ +
+ )} {children}
diff --git a/client/src/components/admin/OverviewTab.tsx b/client/src/components/admin/OverviewTab.tsx new file mode 100644 index 0000000..b0150a6 --- /dev/null +++ b/client/src/components/admin/OverviewTab.tsx @@ -0,0 +1,60 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { MetricCard } from '@/components/MetricCard' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { cn } from '@/lib/utils' + +export function OverviewTab() { + const { data, isLoading } = useQuery({ + queryKey: ['admin-tenants'], + queryFn: () => api.get('/admin/tenants'), + }) + + if (isLoading) return + + const tenants = (data as any)?.data || [] + const activeCount = tenants.filter((t: any) => t.status === 'active').length + const inactiveCount = tenants.filter((t: any) => t.status === 'inactive').length + + return ( +
+
+ + + 0 ? 'warn' : undefined} description="状态为 inactive 的租户" /> + +
+ +
+

租户状态一览

+
+ {tenants.map((t: any) => ( +
+
+ + {t.tenant_name} + {t.tenant_id} +
+
+ DB: {t.db_host}:{t.db_port} + FRP: {t.frp_port} + + {t.status === 'active' ? '运行中' : '已停用'} + +
+
+ ))} + {tenants.length === 0 && ( +

暂无租户,请到「租户管理」创建

+ )} +
+
+
+ ) +} diff --git a/client/src/components/admin/SystemTab.tsx b/client/src/components/admin/SystemTab.tsx new file mode 100644 index 0000000..7bdce2e --- /dev/null +++ b/client/src/components/admin/SystemTab.tsx @@ -0,0 +1,91 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { MetricCard } from '@/components/MetricCard' +import { cn } from '@/lib/utils' + +export function SystemTab() { + const { data: healthData } = useQuery({ + queryKey: ['system-health'], + queryFn: () => api.get('/health'), + refetchInterval: 10000, + }) + + const { data: tenantsData } = useQuery({ + queryKey: ['admin-tenants'], + queryFn: () => api.get('/admin/tenants'), + refetchInterval: 30000, + }) + + const tenants = (tenantsData as any)?.data || [] + const health = (healthData as any)?.data || {} + + return ( +
+
+ + + t.status === 'active').length} status="good" description="运行中的租户数" /> +
+ +
+

租户数据库连接状态

+
+ {tenants.map((t: any) => ( +
+
+ + {t.tenant_name} + {t.tenant_id} +
+
+ {t.db_host}:{t.db_port} + + {t.status === 'active' ? '运行中' : '已停用'} + +
+
+ ))} + {tenants.length === 0 && ( +

暂无租户

+ )} +
+
+ +
+

系统信息

+
+
+ 后端端口 + 9333 +
+
+ 主库 + sbrain_admin (PostgreSQL 16) +
+
+ Nginx 代理 + /api/ → 127.0.0.1:9333 +
+
+ FRP 服务端 + frps :7000 +
+
+ 进程管理 + pm2 +
+
+ 服务器时间 + {health.time ? (health.time as string).substring(0, 19) : '-'} +
+
+
+
+ ) +} diff --git a/client/src/components/admin/TenantsTab.tsx b/client/src/components/admin/TenantsTab.tsx new file mode 100644 index 0000000..6355802 --- /dev/null +++ b/client/src/components/admin/TenantsTab.tsx @@ -0,0 +1,501 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '@/lib/api' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { cn } from '@/lib/utils' +import { Building2, Plus, Trash2, Plug, UserPlus, X, Pencil } from 'lucide-react' + +// 平台只为租户创建管理员(hq 角色) + +export function TenantsTab() { + const qc = useQueryClient() + const [showCreate, setShowCreate] = useState(false) + const [expandedTenant, setExpandedTenant] = useState(null) + const [showUserAdd, setShowUserAdd] = useState(false) + const [showEdit, setShowEdit] = useState(false) + const [editTenantId, setEditTenantId] = useState('') + const [editForm, setEditForm] = useState({ + tenant_name: '', + db_host: '', + db_port: '', + db_name: '', + db_user: '', + db_password: '', + frp_port: '', + }) + const [userForm, setUserForm] = useState({ username: '', password: '', name: '' }) + const [form, setForm] = useState({ + tenant_id: '', + tenant_name: '', + db_host: '127.0.0.1', + db_port: '', + db_name: 'bill_query', + db_user: 'freedak', + db_password: '', + frp_port: '', + }) + + const { data, isLoading } = useQuery({ + queryKey: ['admin-tenants'], + queryFn: () => api.get('/admin/tenants'), + }) + + const tenants = (data as any)?.data || [] + + const { data: tenantDetail } = useQuery({ + queryKey: ['admin-tenant-detail', expandedTenant], + queryFn: () => api.get(`/admin/tenants/${expandedTenant}`), + enabled: !!expandedTenant, + }) + + const createTenant = useMutation({ + mutationFn: (data: any) => api.post('/admin/tenants', data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin-tenants'] }) + setShowCreate(false) + setForm({ tenant_id: '', tenant_name: '', db_host: '127.0.0.1', db_port: '', db_name: 'bill_query', db_user: 'freedak', db_password: '', frp_port: '' }) + }, + }) + + const deleteTenant = useMutation({ + mutationFn: (tenantId: string) => api.delete(`/admin/tenants/${tenantId}`), + onSuccess: (_data: any, tenantId: string) => { + qc.invalidateQueries({ queryKey: ['admin-tenants'] }) + if (expandedTenant === tenantId) setExpandedTenant(null) + }, + }) + + const updateTenantStatus = useMutation({ + mutationFn: ({ tenantId, status }: { tenantId: string; status: string }) => + api.put(`/admin/tenants/${tenantId}`, { status }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-tenants'] }), + }) + + const updateTenant = useMutation({ + mutationFn: ({ tenantId, data }: { tenantId: string; data: any }) => + api.put(`/admin/tenants/${tenantId}`, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin-tenants'] }) + setShowEdit(false) + }, + }) + + const checkStatus = useMutation({ + mutationFn: (tenantId: string) => api.get(`/admin/tenants/${tenantId}/status`), + onSuccess: (data: any) => { + const connected = data.data?.connected + alert(connected ? '数据库连接正常' : `连接失败: ${data.data?.error || '未知错误'}`) + }, + }) + + const addUser = useMutation({ + mutationFn: ({ tenantId, data }: { tenantId: string; data: any }) => + api.post(`/admin/tenants/${tenantId}/users`, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin-tenant-detail', expandedTenant] }) + setShowUserAdd(false) + setUserForm({ username: '', password: '', name: '' }) + }, + }) + + const deleteUser = useMutation({ + mutationFn: ({ tenantId, userId }: { tenantId: string; userId: number }) => + api.delete(`/admin/tenants/${tenantId}/users/${userId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-tenant-detail', expandedTenant] }), + }) + + if (isLoading) return + + const users = (tenantDetail as any)?.data?.users || [] + + return ( +
+
+ +
+ + {/* 租户列表 + 可展开用户管理 */} +
+ {tenants.map((t: any) => ( +
+ {/* 租户行 */} +
+
+ +
+
+ DB: {t.db_host}:{t.db_port}/{t.db_name} + FRP: {t.frp_port} + + updateTenantStatus.mutate({ + tenantId: t.tenant_id, + status: t.status === 'active' ? 'inactive' : 'active', + }) + } + > + {t.status === 'active' ? '运行中' : '已停用'} + + + + +
+
+ + {/* 展开的用户管理区 */} + {expandedTenant === t.tenant_id && ( +
+
+

租户管理员

+ +
+
+ + + + + + + + + + {users.map((u: any) => ( + + + + + + ))} + {users.length === 0 && ( + + + + )} + +
用户名姓名操作
{u.username}{u.name} + +
暂无管理员
+
+
+ )} +
+ ))} + {tenants.length === 0 && ( +
+ +

暂无租户,点击右上角创建

+
+ )} +
+ + {/* 新建租户弹窗 */} + {showCreate && ( +
+
+
+

新建租户

+ +
+
+
+ + setForm({ ...form, tenant_id: e.target.value })} + placeholder="如 tenant_b" + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ + setForm({ ...form, tenant_name: e.target.value })} + placeholder="如 XX餐饮集团" + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+
+ + setForm({ ...form, db_host: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ + setForm({ ...form, db_port: e.target.value })} + placeholder="如 16002" + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+
+
+ + setForm({ ...form, db_name: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ + setForm({ ...form, frp_port: e.target.value })} + placeholder="如 16002" + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+
+
+ + setForm({ ...form, db_user: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ + setForm({ ...form, db_password: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ {createTenant.isError && ( +

{(createTenant.error as any)?.data?.error || '创建失败'}

+ )} + +
+
+
+ )} + + {/* 添加用户弹窗 */} + {showUserAdd && expandedTenant && ( +
+
+
+

添加租户管理员

+ +
+
+
+ + setUserForm({ ...userForm, username: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ + setUserForm({ ...userForm, password: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ + setUserForm({ ...userForm, name: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+ {addUser.isError && ( +

{(addUser.error as any)?.data?.error || '添加失败'}

+ )} + +
+
+
+ )} + + {/* 编辑租户弹窗 */} + {showEdit && ( +
+
+
+

编辑租户

+ +
+
+
+ + setEditForm({ ...editForm, tenant_name: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+
+ + setEditForm({ ...editForm, db_host: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ + setEditForm({ ...editForm, db_port: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+
+
+ + setEditForm({ ...editForm, db_name: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ + setEditForm({ ...editForm, frp_port: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+
+
+ + setEditForm({ ...editForm, db_user: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ + setEditForm({ ...editForm, db_password: e.target.value })} + placeholder="留空不修改" + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ {updateTenant.isError && ( +

{(updateTenant.error as any)?.data?.error || '更新失败'}

+ )} + +
+
+
+ )} +
+ ) +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 4f043c5..3c91a24 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -8,17 +8,19 @@ interface LoginPageProps { export function LoginPage({ onLogin }: LoginPageProps) { const navigate = useNavigate() - const [username, setUsername] = useState('总部管理员') + const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const handleLogin = async () => { try { - const res: any = await api.post('/auth/login', { username, password }) + const isPlatformAdmin = username === '平台管理员' + const endpoint = isPlatformAdmin ? '/admin/login' : '/auth/login' + const res: any = await api.post(endpoint, { username, password }) localStorage.setItem('token', res.data.token) localStorage.setItem('user', JSON.stringify(res.data.user)) onLogin?.(res.data.user) - navigate('/') + navigate(isPlatformAdmin ? '/tenant-management' : '/') } catch { setError('登录失败,请重试') } @@ -56,7 +58,7 @@ export function LoginPage({ onLogin }: LoginPageProps) { 登录

- 可选用户:总部管理员 / 区域经理 / 潘家园店长 / 商品部 + 租户管理员请输入用户名密码登录 · 平台管理员请输入「平台管理员」

diff --git a/client/src/pages/TenantManagementPage.tsx b/client/src/pages/TenantManagementPage.tsx new file mode 100644 index 0000000..ad249f7 --- /dev/null +++ b/client/src/pages/TenantManagementPage.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import { cn } from '@/lib/utils' +import { Building2, LayoutDashboard, Server, Shield } from 'lucide-react' +import { OverviewTab } from '@/components/admin/OverviewTab' +import { TenantsTab } from '@/components/admin/TenantsTab' +import { SystemTab } from '@/components/admin/SystemTab' + +const TABS = [ + { key: 'overview', label: '平台概览', icon: LayoutDashboard }, + { key: 'tenants', label: '租户管理', icon: Building2 }, + { key: 'system', label: '系统监控', icon: Server }, +] + +export function TenantManagementPage() { + const [tab, setTab] = useState('overview') + + return ( +
+
+

+ + 平台管理后台 +

+

SaaS 多租户管理 · 数据库连接配置 · 用户权限管理 · 系统监控

+
+ + {/* Tab 切换 */} +
+ {TABS.map((t) => { + const Icon = t.icon + const active = tab === t.key + return ( + + ) + })} +
+ + {tab === 'overview' && } + {tab === 'tenants' && } + {tab === 'system' && } +
+ ) +} + +// Tab 组件拆分至 @/components/admin/ 目录: +// - OverviewTab.tsx 平台概览 +// - TenantsTab.tsx 租户管理(含用户管理) +// - SystemTab.tsx 系统监控 diff --git a/client/src/pages/TenantUsersPage.tsx b/client/src/pages/TenantUsersPage.tsx new file mode 100644 index 0000000..0d6d8cd --- /dev/null +++ b/client/src/pages/TenantUsersPage.tsx @@ -0,0 +1,275 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '@/lib/api' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { UserPlus, Trash2, X, KeyRound, Users } from 'lucide-react' + +const ROLE_LABELS: Record = { + hq: '总部管理员', + regional: '区域经理', + store: '店长', + dept: '商品部', +} + +export function TenantUsersPage() { + const qc = useQueryClient() + const [showAdd, setShowAdd] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const [editUserId, setEditUserId] = useState(null) + const [addForm, setAddForm] = useState({ username: '', password: '', role: 'regional', name: '', store_code: '', region: '', dept: '' }) + const [passwordForm, setPasswordForm] = useState('') + + const { data, isLoading } = useQuery({ + queryKey: ['tenant-users'], + queryFn: () => api.get('/admin/tenant/users'), + }) + + const addUser = useMutation({ + mutationFn: (data: any) => api.post('/admin/tenant/users', data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['tenant-users'] }) + setShowAdd(false) + setAddForm({ username: '', password: '', role: 'regional', name: '', store_code: '', region: '', dept: '' }) + }, + }) + + const deleteUser = useMutation({ + mutationFn: (userId: number) => api.delete(`/admin/tenant/users/${userId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant-users'] }), + }) + + const updatePassword = useMutation({ + mutationFn: ({ userId, password }: { userId: number; password: string }) => + api.put(`/admin/tenant/users/${userId}/password`, { password }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['tenant-users'] }) + setShowPassword(false) + setPasswordForm('') + setEditUserId(null) + }, + }) + + if (isLoading) return + + const users = (data as any)?.data || [] + + return ( +
+
+
+

+ + 用户管理 +

+

管理本租户的用户账号与权限

+
+ +
+ +
+ + + + + + + + + + + + + + {users.map((u: any) => ( + + + + + + + + + + ))} + {users.length === 0 && ( + + + + )} + +
用户名姓名角色关联信息状态创建时间操作
{u.username}{u.name} + + {ROLE_LABELS[u.role] || u.role} + + + {u.store_code && 门店:{u.store_code}} + {u.region && 门店:{u.region}} + {u.dept && 部门:{u.dept}} + {!u.store_code && !u.region && !u.dept && '-'} + + + {u.is_active ? '启用' : '禁用'} + + + {u.created_at ? String(u.created_at).substring(0, 10) : '-'} + +
+ + +
+
暂无用户
+
+ + {/* 添加用户弹窗 */} + {showAdd && ( +
+
+
+

添加用户

+ +
+
+
+ + setAddForm({ ...addForm, username: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ + setAddForm({ ...addForm, password: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+
+ + +
+
+ + setAddForm({ ...addForm, name: e.target.value })} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+ {addForm.role === 'store' && ( +
+ + setAddForm({ ...addForm, store_code: e.target.value })} + placeholder="如:pjy" + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+ )} + {addForm.role === 'regional' && ( +
+ + setAddForm({ ...addForm, region: e.target.value })} + placeholder="如:0054,0032,0001" + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+ )} + {addForm.role === 'dept' && ( +
+ + setAddForm({ ...addForm, dept: e.target.value })} + placeholder="如:商品部" + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+ )} + {addUser.isError && ( +

{(addUser.error as any)?.data?.error || '添加失败'}

+ )} + +
+
+
+ )} + + {/* 修改密码弹窗 */} + {showPassword && editUserId !== null && ( +
+
+
+

修改密码

+ +
+
+
+ + setPasswordForm(e.target.value)} + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+ {updatePassword.isError && ( +

{(updatePassword.error as any)?.data?.error || '修改失败'}

+ )} + +
+
+
+ )} +
+ ) +} diff --git a/deploy-server.sh b/deploy-server.sh deleted file mode 100644 index f721fcd..0000000 --- a/deploy-server.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -# ============================================================ -# 部署后端到 dm.all8ai.top 服务器 -# 用法: bash deploy-server.sh -# ============================================================ - -set -e - -SERVER_IP="152.136.182.184" -SERVER_USER="ubuntu" -REMOTE_DIR="/opt/sbrain-server" - -PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" -SERVER_DIR="$PROJECT_DIR/server" - -echo "==========================================" -echo " 部署后端到 dm.all8ai.top" -echo "==========================================" -echo "" - -# 1. 创建远程目录 -echo "[1/5] 准备远程目录..." -ssh "$SERVER_USER@$SERVER_IP" "mkdir -p $REMOTE_DIR" -echo "✓ 目录就绪" -echo "" - -# 2. 同步代码(排除 node_modules、.env、dist) -echo "[2/5] 同步代码..." -rsync -avz --delete \ - --exclude='node_modules' \ - --exclude='.env' \ - --exclude='dist' \ - --exclude='.git' \ - "$SERVER_DIR/" "$SERVER_USER@$SERVER_IP:$REMOTE_DIR/" -echo "✓ 代码同步完成" -echo "" - -# 3. 安装依赖 -echo "[3/5] 安装依赖..." -ssh "$SERVER_USER@$SERVER_IP" "cd $REMOTE_DIR && npm install --production" -echo "✓ 依赖安装完成" -echo "" - -# 4. 创建 .env(服务器专用配置) -echo "[4/5] 写入服务器配置..." -ssh "$SERVER_USER@$SERVER_IP" "cat > $REMOTE_DIR/.env << 'ENVEOF' -DB_HOST=127.0.0.1 -DB_PORT=15432 -DB_NAME=bill_query -DB_USER=freedak -DB_PASSWORD= -DB_POOL_MAX=10 - -PORT=9333 -JWT_SECRET=malan-ops-2026-secret-key -CLIENT_URL=https://dm.all8ai.top -ZHIPU_API_KEY=sk-c0c5174892c44ff48d587cd040fbdd40 -ENVEOF" -echo "✓ 配置写入完成" -echo "" - -# 5. 用 pm2 重启后端 -echo "[5/5] 重启后端服务..." -ssh "$SERVER_USER@$SERVER_IP" "cd $REMOTE_DIR && pm2 delete sbrain-server 2>/dev/null; pm2 start 'npx tsx src/index.ts' --name sbrain-server && pm2 save" -echo "✓ 后端已启动" -echo "" - -# 验证 -echo "==========================================" -echo " 部署成功!" -echo " 后端运行在服务器 $SERVER_IP:9333" -echo " 日志: ssh $SERVER_USER@$SERVER_IP 'pm2 logs sbrain-server'" -echo "==========================================" diff --git a/deploy.sh b/deploy.sh index 55b79b5..71971b5 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,7 +1,10 @@ #!/bin/bash # ============================================================ -# 一键部署前端到 dm.all8ai.top 服务器 -# 用法: bash deploy.sh +# 一键部署前端+后端到 dm.all8ai.top 服务器 +# 用法: +# bash deploy.sh # 全量部署(后端+前端) +# bash deploy.sh server # 只部署后端 +# bash deploy.sh front # 只部署前端 # ============================================================ set -e @@ -9,38 +12,133 @@ set -e # 服务器配置 SERVER_IP="152.136.182.184" SERVER_USER="ubuntu" -REMOTE_PATH="/var/www/dm.all8ai.top" +REMOTE_WEB="/var/www/dm.all8ai.top" +REMOTE_API="/opt/sbrain-server" # 项目路径 PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" CLIENT_DIR="$PROJECT_DIR/client" +SERVER_DIR="$PROJECT_DIR/server" -echo "==========================================" -echo " 部署前端到 dm.all8ai.top" -echo "==========================================" -echo "" +TARGET="${1:-all}" -# 1. 构建前端 -echo "[1/3] 构建前端..." -cd "$CLIENT_DIR" -npm run build -echo "✓ 构建完成" -echo "" +# ============================================================ +# 部署后端 +# ============================================================ +deploy_server() { + echo "==========================================" + echo " 部署后端到 dm.all8ai.top" + echo "==========================================" + echo "" -# 2. 清理服务器旧文件 -echo "[2/3] 清理服务器旧文件..." -ssh "$SERVER_USER@$SERVER_IP" "rm -rf $REMOTE_PATH/assets" -echo "✓ 旧文件已清理" -echo "" + # 1. 同步代码 + echo "[1/4] 同步代码..." + rsync -avz --delete \ + --exclude='node_modules' \ + --exclude='.env' \ + --exclude='dist' \ + --exclude='.git' \ + "$SERVER_DIR/" "$SERVER_USER@$SERVER_IP:$REMOTE_API/" + echo "✓ 代码同步完成" + echo "" -# 3. 上传新构建产物 -echo "[3/3] 上传构建产物..." -scp -r "$CLIENT_DIR/dist/"* "$SERVER_USER@$SERVER_IP:$REMOTE_PATH/" -echo "✓ 上传完成" -echo "" + # 2. 安装依赖 + echo "[2/4] 安装依赖..." + ssh "$SERVER_USER@$SERVER_IP" "cd $REMOTE_API && npm install --production" + echo "✓ 依赖安装完成" + echo "" -# 验证 -echo "==========================================" -echo " 部署成功!" -echo " 访问: https://dm.all8ai.top" -echo "==========================================" + # 3. 写入服务器 .env + echo "[3/4] 写入服务器配置..." + ssh "$SERVER_USER@$SERVER_IP" "cat > $REMOTE_API/.env << 'ENVEOF' +DB_HOST=127.0.0.1 +DB_PORT=15432 +DB_NAME=bill_query +DB_USER=freedak +DB_PASSWORD= +DB_POOL_MAX=10 + +PORT=9333 +JWT_SECRET=malan-ops-2026-secret-key +CLIENT_URL=https://dm.all8ai.top +ZHIPU_API_KEY=sk-c0c5174892c44ff48d587cd040fbdd40 + +ADMIN_DB_HOST=127.0.0.1 +ADMIN_DB_PORT=5432 +ADMIN_DB_NAME=sbrain_admin +ADMIN_DB_USER=sbrain_admin +ADMIN_DB_PASSWORD=sbrain2026 +ENVEOF" + echo "✓ 配置写入完成" + echo "" + + # 4. pm2 重启 + echo "[4/4] 重启后端服务..." + ssh "$SERVER_USER@$SERVER_IP" "cd $REMOTE_API && pm2 delete sbrain-server 2>/dev/null; pm2 start 'npx tsx src/index.ts' --name sbrain-server && pm2 save" + echo "✓ 后端已启动" + echo "" + + echo "==========================================" + echo " 后端部署成功!端口 9333" + echo " 日志: ssh $SERVER_USER@$SERVER_IP 'pm2 logs sbrain-server'" + echo "==========================================" + echo "" +} + +# ============================================================ +# 部署前端 +# ============================================================ +deploy_front() { + echo "==========================================" + echo " 部署前端到 dm.all8ai.top" + echo "==========================================" + echo "" + + # 1. 构建前端 + echo "[1/3] 构建前端..." + cd "$CLIENT_DIR" + npm run build + echo "✓ 构建完成" + echo "" + + # 2. 清理服务器旧文件 + echo "[2/3] 清理服务器旧文件..." + ssh "$SERVER_USER@$SERVER_IP" "rm -rf $REMOTE_WEB/assets" + echo "✓ 旧文件已清理" + echo "" + + # 3. 上传构建产物 + echo "[3/3] 上传构建产物..." + scp -r "$CLIENT_DIR/dist/"* "$SERVER_USER@$SERVER_IP:$REMOTE_WEB/" + echo "✓ 上传完成" + echo "" + + echo "==========================================" + echo " 前端部署成功!" + echo " 访问: https://dm.all8ai.top" + echo "==========================================" + echo "" +} + +# ============================================================ +# 执行 +# ============================================================ +case "$TARGET" in + server) + deploy_server + ;; + front) + deploy_front + ;; + all) + deploy_server + deploy_front + ;; + *) + echo "用法: bash deploy.sh [all|server|front]" + echo " all - 部署后端+前端(默认)" + echo " server - 只部署后端" + echo " front - 只部署前端" + exit 1 + ;; +esac diff --git a/run.md b/run.md index 96659b9..4dc17db 100644 --- a/run.md +++ b/run.md @@ -2,43 +2,81 @@ ## 登录账号 -| 用户名 | 密码 | 角色 | -|--------|------|------| -| 总部管理员 | 123 | hq | -| 区域经理 | 123 | regional | -| 潘家园店长 | 123 | store | -| 商品部 | 123 | dept | +### 平台管理员 -## 架构(2026-08-02 更新:SaaS + 本地数据库) +| 用户名 | 密码 | 角色 | 说明 | +|--------|------|------|------| +| 平台管理员 | 123 | platform_admin | 管理所有租户,登录后进入平台管理后台 | + +### 租户用户(存储在服务器 sbrain_admin 数据库) + +| 用户名 | 密码 | 角色 | 租户 | +|--------|------|------|------| +| 总部管理员 | 123 | hq | tenant_a(西部马华) | + +> 租户管理员(hq)登录后可在系统内创建区域经理、店长、商品部等角色用户。 +> 密码使用 bcrypt 加密存储。 + +## 架构(2026-08-02 更新:SaaS 多租户) ``` 浏览器 → https://dm.all8ai.top → Nginx(服务器) → /api/ → 127.0.0.1:9333 (服务器后端, pm2管理) - → frp隧道 127.0.0.1:15432 → 本机PostgreSQL:5432 + → 服务器本地 PostgreSQL:5432 → sbrain_admin(平台库:租户配置、用户认证) + → frp隧道 127.0.0.1:15432 → 本机PostgreSQL:5432 → bill_query(租户业务库) ``` -- **前端**:服务器 Nginx 静态文件 `/var/www/dm.all8ai.top` -- **后端**:服务器 `/opt/sbrain-server`,pm2 管理,端口 9333 -- **数据库**:本机 PostgreSQL 15,端口 5432,数据库 bill_query -- **frp 隧道**:暴露 PostgreSQL(5432→15432),服务器后端通过 15432 连接本机数据库 +### 数据库连接(重要) + +后端连接两个不同的数据库,走不同端口: + +| 连接池 | 目标数据库 | 连接地址 | 认证方式 | 配置来源 | +|--------|-----------|---------|---------|---------| +| `adminPool` | `sbrain_admin`(平台库) | `127.0.0.1:5432`(服务器本地 PG) | scram-sha-256(用户 `sbrain_admin`,密码 `sbrain2026`) | `.env` 中 `ADMIN_DB_*` | +| `tenantPool` | `bill_query`(业务库) | `127.0.0.1:15432`(FRP 隧道 → 本机 PG) | trust(用户 `freedak`,无需密码) | `tenant_configs.db_port=15432` | +| `pool` | `bill_query`(业务库) | `127.0.0.1:15432`(FRP 隧道 → 本机 PG) | trust(用户 `freedak`,无需密码) | `.env` 中 `DB_*` | + +> ⚠️ `tenant_configs` 表中 `db_port` 必须为 `15432`(FRP 隧道),`db_password` 为 NULL(trust 认证)。 +> ⚠️ `adminPool` 连接服务器本地 5432,需要 `ADMIN_DB_PASSWORD=sbrain2026`。 + +### 数据库分布 + +| 数据库 | 位置 | 存储内容 | +|--------|------|---------| +| `sbrain_admin` | 服务器本地 PostgreSQL(5432) | 平台管理员、租户配置(tenant_configs)、所有租户用户(tenant_users) | +| `bill_query` | 本机 PostgreSQL(通过 frp 暴露为 15432) | 租户业务数据(销售、任务、成本分析等) | + +### 多租户数据隔离 + +- **认证流程**:用户登录 → 查 `sbrain_admin.tenant_users` → bcrypt 验证 → JWT 含 `tenantId` +- **数据隔离**:业务请求 → `tenantDbMiddleware` 根据 JWT 中的 `tenantId` 查 `tenant_configs` 获取该租户数据库连接信息 → `AsyncLocalStorage` 设置租户连接池 → 所有 `query()` 自动走对应租户数据库 +- **租户用户管理**:平台管理员为租户创建管理员(hq),租户管理员在系统内创建区域经理/店长/商品部等用户,均存储在 `sbrain_admin.tenant_users` 表中,通过 `tenant_id` 字段区分 + +### 关键文件 + +| 文件 | 职责 | +|------|------| +| `server/src/config/tenant-db.ts` | 租户连接池缓存 + AsyncLocalStorage | +| `server/src/config/admin-pool.ts` | 平台库连接池(通过 `ADMIN_DB_*` 环境变量配置) | +| `server/src/config/database.ts` | 主业务库连接池 + 全局 `query()` 函数(自动注入数据权限) | +| `server/src/middleware/tenant-db.ts` | 根据 JWT tenantId 动态切换数据库 + 注入用户 scope | +| `server/src/middleware/data-scope.ts` | 数据权限工具函数(`getDataScope` / `scopeStoreFilter`) | +| `server/src/routes/auth.ts` | 租户用户登录(查 tenant_users + bcrypt) | +| `server/src/routes/admin.ts` | 平台管理 API + 租户内用户管理 API | +| `server/sql/11_tenant_management.sql` | 平台数据库建表 SQL | ## 部署流程 -### 前端部署 +### 一键部署(前端+后端) ```bash -bash deploy.sh +bash deploy.sh # 全量部署(后端+前端) +bash deploy.sh server # 只部署后端 +bash deploy.sh front # 只部署前端 ``` -构建前端 + scp 上传到服务器 `/var/www/dm.all8ai.top`。 - -### 后端部署 - -```bash -bash deploy-server.sh -``` - -rsync 同步代码到服务器 `/opt/sbrain-server` + npm install + pm2 重启。 +- 后端:rsync 同步到服务器 `/opt/sbrain-server` + npm install + pm2 重启 +- 前端:构建 + scp 上传到服务器 `/var/www/dm.all8ai.top` ### 本机 frp 隧道(launchd 系统级服务) @@ -123,11 +161,19 @@ ssh ubuntu@152.136.182.184 'cat /opt/sbrain-server/.env' ## 注意事项 -- 本机 PostgreSQL 端口 5432 被 Homebrew PostgreSQL 占用,数据库 `bill_query` 在本地 -- frp 隧道将远程 15432 映射到本地 5432,服务器后端通过 15432 连接本机数据库 +- **平台库 `sbrain_admin` 在服务器本地 PostgreSQL(5432)**,业务库 `bill_query` 在本机 PostgreSQL(通过 frp 暴露为 15432) +- `adminPool` 连服务器本地 5432 的 `sbrain_admin`(SCRAM 认证,用户 `sbrain_admin`,密码 `sbrain2026`) +- `tenantPool` / `pool` 连 FRP 隧道 15432 的 `bill_query`(trust 认证,用户 `freedak`,无需密码) +- `tenant_configs` 表中 `db_port` 必须为 `15432`,`db_password` 为 NULL(trust 认证) +- `pg` 库在 `password` 为空字符串时会触发 SCRAM 认证失败,因此 `admin-pool.ts`、`database.ts`、`tenant-db.ts` 中空密码时不传 `password` 参数 +- `deploy.sh` 每次部署会覆盖服务器 `.env`,其中包含 `ADMIN_DB_*` 配置(`ADMIN_DB_PORT=5432`,`ADMIN_DB_PASSWORD=sbrain2026`),修改 `.env` 配置必须同步修改 `deploy.sh` +- 租户用户统一存储在 `sbrain_admin.tenant_users` 表,通过 `tenant_id` 区分租户 +- `tenant_users.region` 字段为 `VARCHAR(500)`,区域经理的 `region` 存储逗号分隔的 `store_code` 列表(如 `0010,0011,0014`) +- 密码使用 bcrypt 加密,平台管理员密码目前仍为明文(后续可改) - 本机只需运行 frpc(launchd 自启),后端已部署到服务器 -- 修改后端代码后执行 `bash deploy-server.sh` 部署到服务器 -- 修改前端代码后执行 `bash deploy.sh` 部署到服务器 +- 修改后端代码后执行 `bash deploy.sh server` 部署到服务器 +- 修改前端代码后执行 `bash deploy.sh front` 部署到服务器 +- 全量部署执行 `bash deploy.sh`(后端+前端) - `server/.env`(本机开发用)中 `DB_PORT=5432` 连接本地数据库,不要改为 5433 - 服务器 `/opt/sbrain-server/.env` 中 `DB_PORT=15432` 连接 frp 隧道端口 - frpc 断开后线上前端 API 会失效,需重新启动 frpc diff --git a/server/package-lock.json b/server/package-lock.json index d22d5f8..e569f41 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,6 +8,7 @@ "name": "sbrain-server", "version": "1.0.0", "dependencies": { + "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", @@ -15,6 +16,7 @@ "pg": "^8.12.0" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.6", @@ -466,6 +468,16 @@ "node": ">=18" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -643,6 +655,20 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/body-parser": { "version": "1.20.6", "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.6.tgz", @@ -1330,6 +1356,26 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.9.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-8.9.1.tgz", + "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmmirror.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", diff --git a/server/package.json b/server/package.json index 6002864..2d9c0e5 100644 --- a/server/package.json +++ b/server/package.json @@ -9,6 +9,7 @@ "test": "tsx src/tests/run-tests.ts" }, "dependencies": { + "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", @@ -16,6 +17,7 @@ "pg": "^8.12.0" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.6", diff --git a/server/sql/11_tenant_management.sql b/server/sql/11_tenant_management.sql new file mode 100644 index 0000000..d416948 --- /dev/null +++ b/server/sql/11_tenant_management.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS tenant_configs ( + tenant_id VARCHAR(50) PRIMARY KEY, + tenant_name VARCHAR(100) NOT NULL, + db_host VARCHAR(100) NOT NULL DEFAULT '127.0.0.1', + db_port INTEGER NOT NULL, + db_name VARCHAR(100) NOT NULL DEFAULT 'bill_query', + db_user VARCHAR(50) DEFAULT 'postgres', + db_password VARCHAR(100) DEFAULT '', + frp_port INTEGER NOT NULL, + status VARCHAR(20) DEFAULT 'inactive', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tenant_users ( + id SERIAL PRIMARY KEY, + tenant_id VARCHAR(50) REFERENCES tenant_configs(tenant_id) ON DELETE CASCADE, + username VARCHAR(50) NOT NULL, + password VARCHAR(200) NOT NULL, + role VARCHAR(20) NOT NULL, + name VARCHAR(50), + store_code VARCHAR(20), + region VARCHAR(500), + dept VARCHAR(50), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(tenant_id, username) +); + +CREATE TABLE IF NOT EXISTS platform_admins ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password VARCHAR(200) NOT NULL, + name VARCHAR(50) NOT NULL +); + +INSERT INTO platform_admins (username, password, name) VALUES ('平台管理员', '123', '平台管理员') ON CONFLICT DO NOTHING; + +INSERT INTO tenant_configs (tenant_id, tenant_name, db_port, frp_port, status) VALUES ('tenant_a', '默认租户', 15432, 15432, 'active') ON CONFLICT DO NOTHING; diff --git a/server/src/config/admin-pool.ts b/server/src/config/admin-pool.ts new file mode 100644 index 0000000..45c49e7 --- /dev/null +++ b/server/src/config/admin-pool.ts @@ -0,0 +1,24 @@ +import pg from 'pg' + +const { Pool } = pg + +const adminPoolConfig: any = { + host: process.env.ADMIN_DB_HOST || '127.0.0.1', + port: parseInt(process.env.ADMIN_DB_PORT || '5432'), + database: process.env.ADMIN_DB_NAME || 'sbrain_admin', + user: process.env.ADMIN_DB_USER || 'sbrain_admin', + max: 5, +} + +const adminDbPassword = process.env.ADMIN_DB_PASSWORD +if (adminDbPassword) { + adminPoolConfig.password = adminDbPassword +} + +const adminPool = new Pool(adminPoolConfig) + +adminPool.on('error', (err) => { + console.error('Unexpected error on admin pool', err) +}) + +export default adminPool diff --git a/server/src/config/database.ts b/server/src/config/database.ts index 519ca0d..961866a 100644 --- a/server/src/config/database.ts +++ b/server/src/config/database.ts @@ -1,15 +1,19 @@ import pg from 'pg' +import { tenantContextStorage } from './tenant-db.js' const { Pool } = pg -const pool = new Pool({ +const poolConfig: any = { host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432'), database: process.env.DB_NAME || 'bill_query', user: process.env.DB_USER || 'freedak', - password: process.env.DB_PASSWORD || '', max: parseInt(process.env.DB_POOL_MAX || '10'), -}) +} +if (process.env.DB_PASSWORD) { + poolConfig.password = process.env.DB_PASSWORD +} +const pool = new Pool(poolConfig) pool.on('error', (err) => { console.error('Unexpected error on idle client', err) @@ -20,18 +24,61 @@ export interface QueryResult { rowCount: number | null } -export async function query(text: string, params?: any[]): Promise> { +export async function query(text: string, params?: any[], opts?: { skipScope?: boolean }): Promise> { + const ctx = tenantContextStorage.getStore() + const usePool = ctx ? ctx.pool : pool const start = Date.now() - const res = await pool.query(text, params) + + let sql = text + let sqlParams = params || [] + + if (!opts?.skipScope && ctx?.scope && (ctx.scope.role === 'store' || ctx.scope.role === 'regional')) { + const sqlLower = sql.toLowerCase() + const hasStoreRef = /store_code|store_name|v_store_|mv_store_|bill_fact|fact_bill|store_task|dim_store/.test(sqlLower) + if (!hasStoreRef) { + const res = await usePool.query(sql, sqlParams) + const duration = Date.now() - start + if (duration > 500) { + console.warn(`Slow query (${duration}ms):`, sql.substring(0, 100)) + } + return res + } + + const scopeClause = ctx.scope.role === 'store' && ctx.scope.storeCode + ? ` store_code = $${sqlParams.length + 1}` + : ctx.scope.role === 'regional' && ctx.scope.region + ? ` store_code = ANY($${sqlParams.length + 1})` + : null + + if (scopeClause) { + const scopeValue = ctx.scope.role === 'store' + ? ctx.scope.storeCode + : ctx.scope.region!.split(',').map(s => s.trim()).filter(Boolean) + sqlParams = [...sqlParams, scopeValue] + if (sql.includes('WHERE')) { + sql = sql.replace('WHERE', `WHERE${scopeClause} AND`) + } else if (sql.includes('GROUP BY')) { + sql = sql.replace('GROUP BY', `WHERE${scopeClause} GROUP BY`) + } else if (sql.includes('ORDER BY')) { + sql = sql.replace('ORDER BY', `WHERE${scopeClause} ORDER BY`) + } else { + sql = sql + ` WHERE${scopeClause}` + } + } + } + + const res = await usePool.query(sql, sqlParams) const duration = Date.now() - start if (duration > 500) { - console.warn(`Slow query (${duration}ms):`, text.substring(0, 100)) + console.warn(`Slow query (${duration}ms):`, sql.substring(0, 100)) } return res } export async function withTransaction(callback: (client: pg.PoolClient) => Promise): Promise { - const client = await pool.connect() + const ctx = tenantContextStorage.getStore() + const usePool = ctx ? ctx.pool : pool + const client = await usePool.connect() try { await client.query('BEGIN') const result = await callback(client) diff --git a/server/src/config/tenant-db.ts b/server/src/config/tenant-db.ts new file mode 100644 index 0000000..ee773c2 --- /dev/null +++ b/server/src/config/tenant-db.ts @@ -0,0 +1,74 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import pg from 'pg' + +const { Pool } = pg + +export interface TenantContext { + tenantId: string + pool: pg.Pool + scope?: { role: string; storeCode?: string; region?: string } | null +} + +export const tenantContextStorage = new AsyncLocalStorage() + +const poolCache = new Map() +const IDLE_TIMEOUT = 10 * 60 * 1000 + +export function getTenantPool(config: { + tenantId: string + dbHost: string + dbPort: number + dbName: string + dbUser: string + dbPassword?: string +}): pg.Pool { + const key = config.tenantId + const cached = poolCache.get(key) + if (cached) { + cached.lastUsed = Date.now() + return cached.pool + } + const poolConfig: any = { + host: config.dbHost, + port: config.dbPort, + database: config.dbName, + user: config.dbUser, + max: 5, + idleTimeoutMillis: 30000, + } + if (config.dbPassword) { + poolConfig.password = config.dbPassword + } + const pool = new Pool(poolConfig) + pool.on('error', (err) => { + console.error(`Tenant pool [${key}] error:`, err) + closeTenantPool(key) + }) + poolCache.set(key, { pool, lastUsed: Date.now() }) + return pool +} + +export function closeTenantPool(tenantId: string) { + const cached = poolCache.get(tenantId) + if (cached) { + cached.pool.end() + poolCache.delete(tenantId) + } +} + +export function closeAllTenantPools() { + for (const [, cached] of poolCache) { + cached.pool.end() + } + poolCache.clear() +} + +setInterval(() => { + const now = Date.now() + for (const [id, cached] of poolCache) { + if (now - cached.lastUsed > IDLE_TIMEOUT) { + console.log(`Closing idle tenant pool [${id}]`) + closeTenantPool(id) + } + } +}, 5 * 60 * 1000) diff --git a/server/src/index.ts b/server/src/index.ts index de2e146..5b72eb2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,6 +3,7 @@ import express from 'express' import cors from 'cors' import { authMiddleware, AuthRequest } from './middleware/auth.js' import { errorHandler, notFoundHandler } from './middleware/error.js' +import { tenantDbMiddleware } from './middleware/tenant-db.js' import authRoutes from './routes/auth.js' import dataRoutes from './routes/data.js' import taskRoutes from './routes/tasks.js' @@ -11,6 +12,7 @@ import storeExpenseRoutes from './routes/store-expense.js' import smartSchedulingRoutes from './routes/smart-scheduling.js' import situationalAwarenessRoutes from './routes/situational-awareness.js' import analyticsEnhancedRoutes from './routes/analytics-enhanced.js' +import adminRoutes from './routes/admin.js' const app = express() const PORT = parseInt(process.env.PORT || '3333') @@ -27,6 +29,12 @@ app.get('/api/health', (req, res) => { app.use('/api/auth', authRoutes) +// admin login 不需要 auth 中间件 +app.post('/api/admin/login', (req, res, next) => { + req.url = '/login' + adminRoutes(req, res, next) +}) + app.use((req, res, next) => { if (req.path === '/api/health' || req.path.startsWith('/api/auth')) { return next() @@ -34,6 +42,16 @@ app.use((req, res, next) => { authMiddleware(req as AuthRequest, res, next) }) +app.use('/api/admin', adminRoutes) + +// 租户数据隔离中间件:根据 JWT 中的 tenantId 动态切换数据库连接池 +app.use((req, res, next) => { + if (req.path.startsWith('/api/admin') || req.path === '/api/health' || req.path.startsWith('/api/auth')) { + return next() + } + tenantDbMiddleware(req as AuthRequest, res, next) +}) + app.use('/api', dataRoutes) app.use('/api/tasks', taskRoutes) app.use('/api/cost-analysis', costAnalysisRoutes) diff --git a/server/src/middleware/data-scope.ts b/server/src/middleware/data-scope.ts new file mode 100644 index 0000000..e4f0b76 --- /dev/null +++ b/server/src/middleware/data-scope.ts @@ -0,0 +1,56 @@ +import { AuthRequest } from './auth.js' + +export interface DataScope { + role: string + storeCode?: string + region?: string + dept?: string +} + +export function getDataScope(req: AuthRequest): DataScope | null { + if (!req.user) return null + + if (req.user.role === 'hq' || req.user.role === 'platform_admin' || req.user.role === 'dept') { + return null + } + + if (req.user.role === 'store' && req.user.storeCode) { + return { + role: 'store', + storeCode: req.user.storeCode, + } + } + + if (req.user.role === 'regional' && req.user.region) { + return { + role: 'regional', + region: req.user.region, + } + } + + return null +} + +export function scopeStoreFilter(scope: DataScope | null, existingParams: any[]): { clause: string; params: any[] } { + if (!scope) return { clause: '', params: existingParams } + + if (scope.role === 'store' && scope.storeCode) { + const idx = existingParams.length + 1 + return { + clause: ` AND store_code = $${idx}`, + params: [...existingParams, scope.storeCode], + } + } + + if (scope.role === 'regional' && scope.region) { + const codes = scope.region.split(',').map(s => s.trim()).filter(Boolean) + if (codes.length === 0) return { clause: '', params: existingParams } + const idx = existingParams.length + 1 + return { + clause: ` AND store_code = ANY($${idx})`, + params: [...existingParams, codes], + } + } + + return { clause: '', params: existingParams } +} diff --git a/server/src/middleware/tenant-db.ts b/server/src/middleware/tenant-db.ts new file mode 100644 index 0000000..e004320 --- /dev/null +++ b/server/src/middleware/tenant-db.ts @@ -0,0 +1,46 @@ +import { Response, NextFunction } from 'express' +import { AuthRequest } from './auth.js' +import { tenantContextStorage, getTenantPool } from '../config/tenant-db.js' +import { sendError } from './error.js' +import adminPool from '../config/admin-pool.js' + +export async function tenantDbMiddleware(req: AuthRequest, res: Response, next: NextFunction) { + if (!req.user || !req.user.tenantId) { + return next() + } + + try { + const result = await adminPool.query( + 'SELECT tenant_id, db_host, db_port, db_name, db_user, db_password, status FROM tenant_configs WHERE tenant_id = $1', + [req.user.tenantId] + ) + + if (result.rows.length === 0) { + return sendError(res, 'Tenant not found', 404) + } + + const config = result.rows[0] + if (config.status !== 'active') { + return sendError(res, 'Tenant is inactive', 403) + } + + const tenantPool = getTenantPool({ + tenantId: config.tenant_id, + dbHost: config.db_host, + dbPort: config.db_port, + dbName: config.db_name, + dbUser: config.db_user, + dbPassword: config.db_password, + }) + + tenantContextStorage.run({ tenantId: config.tenant_id, pool: tenantPool, scope: req.user ? { + role: req.user.role, + storeCode: req.user.storeCode, + region: req.user.region, + } : null }, () => { + next() + }) + } catch (err: any) { + return sendError(res, `Tenant DB error: ${err.message}`) + } +} diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts new file mode 100644 index 0000000..3101c8b --- /dev/null +++ b/server/src/routes/admin.ts @@ -0,0 +1,337 @@ +import { Router } from 'express' +import pg from 'pg' +import bcrypt from 'bcrypt' +import { sendSuccess, sendError } from '../middleware/error.js' +import { generateToken, AuthRequest } from '../middleware/auth.js' +import adminPool from '../config/admin-pool.js' +import type { AuthUser } from '../types/index.js' + +const { Pool } = pg +const router = Router() + +// 平台管理员登录 +router.post('/login', async (req, res) => { + const { username, password } = req.body + if (!username || !password) { + return sendError(res, 'Username and password required') + } + try { + const result = await adminPool.query( + 'SELECT * FROM platform_admins WHERE username = $1', + [username] + ) + if (result.rows.length === 0) { + return sendError(res, 'Invalid credentials', 401) + } + const admin = result.rows[0] + const valid = await bcrypt.compare(password, admin.password) + if (!valid) { + return sendError(res, 'Invalid credentials', 401) + } + const user: AuthUser = { + id: `admin_${admin.id}`, + role: 'platform_admin' as any, + name: admin.name, + } + const token = generateToken(user) + sendSuccess(res, { token, user }) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 平台管理员认证中间件 +async function requirePlatformAdmin(req: AuthRequest, res: any, next: any) { + if (!req.user || (req.user as any).role !== 'platform_admin') { + return sendError(res, 'Platform admin access required', 403) + } + next() +} + +// 获取所有租户 +router.get('/tenants', requirePlatformAdmin, async (req, res) => { + try { + const result = await adminPool.query( + 'SELECT tenant_id, tenant_name, db_host, db_port, db_name, db_user, frp_port, status, created_at, updated_at FROM tenant_configs ORDER BY created_at DESC' + ) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 获取单个租户详情 +router.get('/tenants/:tenantId', requirePlatformAdmin, async (req, res) => { + try { + const { tenantId } = req.params + const result = await adminPool.query( + 'SELECT tenant_id, tenant_name, db_host, db_port, db_name, db_user, frp_port, status, created_at, updated_at FROM tenant_configs WHERE tenant_id = $1', + [tenantId] + ) + if (result.rows.length === 0) { + return sendError(res, 'Tenant not found', 404) + } + const users = await adminPool.query( + 'SELECT id, tenant_id, username, role, name FROM tenant_users WHERE tenant_id = $1 ORDER BY id', + [tenantId] + ) + sendSuccess(res, { ...result.rows[0], users: users.rows }) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 创建租户 +router.post('/tenants', requirePlatformAdmin, async (req, res) => { + const { tenant_id, tenant_name, db_host, db_port, db_name, db_user, db_password, frp_port } = req.body + if (!tenant_id || !tenant_name || !db_port || !frp_port) { + return sendError(res, 'tenant_id, tenant_name, db_port, frp_port are required') + } + try { + const result = await adminPool.query( + `INSERT INTO tenant_configs (tenant_id, tenant_name, db_host, db_port, db_name, db_user, db_password, frp_port, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'inactive') + RETURNING tenant_id, tenant_name, db_host, db_port, db_name, db_user, frp_port, status, created_at`, + [tenant_id, tenant_name, db_host || '127.0.0.1', db_port, db_name || 'bill_query', db_user || 'postgres', db_password || '', frp_port] + ) + sendSuccess(res, result.rows[0]) + } catch (err: any) { + if (err.code === '23505') { + return sendError(res, 'Tenant ID already exists') + } + sendError(res, err.message) + } +}) + +// 更新租户 +router.put('/tenants/:tenantId', requirePlatformAdmin, async (req, res) => { + const { tenantId } = req.params + const { tenant_name, db_host, db_port, db_name, db_user, db_password, frp_port, status } = req.body + try { + const result = await adminPool.query( + `UPDATE tenant_configs SET + tenant_name = COALESCE($1, tenant_name), + db_host = COALESCE($2, db_host), + db_port = COALESCE($3, db_port), + db_name = COALESCE($4, db_name), + db_user = COALESCE($5, db_user), + db_password = COALESCE($6, db_password), + frp_port = COALESCE($7, frp_port), + status = COALESCE($8, status), + updated_at = NOW() + WHERE tenant_id = $9 + RETURNING tenant_id, tenant_name, db_host, db_port, db_name, db_user, frp_port, status, updated_at`, + [tenant_name, db_host, db_port, db_name, db_user, db_password, frp_port, status, tenantId] + ) + if (result.rows.length === 0) { + return sendError(res, 'Tenant not found', 404) + } + sendSuccess(res, result.rows[0]) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 删除租户 +router.delete('/tenants/:tenantId', requirePlatformAdmin, async (req, res) => { + const { tenantId } = req.params + try { + const result = await adminPool.query( + 'DELETE FROM tenant_configs WHERE tenant_id = $1 RETURNING tenant_id', + [tenantId] + ) + if (result.rows.length === 0) { + return sendError(res, 'Tenant not found', 404) + } + sendSuccess(res, { deleted: true }) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 检测租户数据库连接状态 +router.get('/tenants/:tenantId/status', requirePlatformAdmin, async (req, res) => { + const { tenantId } = req.params + try { + const result = await adminPool.query( + 'SELECT db_host, db_port, db_name, db_user, db_password FROM tenant_configs WHERE tenant_id = $1', + [tenantId] + ) + if (result.rows.length === 0) { + return sendError(res, 'Tenant not found', 404) + } + const cfg = result.rows[0] + const testPool = new Pool({ + host: cfg.db_host, + port: cfg.db_port, + database: cfg.db_name, + user: cfg.db_user, + password: cfg.db_password, + max: 1, + connectionTimeoutMillis: 3000, + }) + try { + const client = await testPool.connect() + client.release() + await testPool.end() + sendSuccess(res, { connected: true }) + } catch (err: any) { + await testPool.end() + sendSuccess(res, { connected: false, error: err.message }) + } + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 为租户添加用户 +router.post('/tenants/:tenantId/users', requirePlatformAdmin, async (req, res) => { + const { tenantId } = req.params + const { username, password, role, name } = req.body + if (!username || !password || !role) { + return sendError(res, 'username, password, role are required') + } + try { + const hashedPassword = await bcrypt.hash(password, 10) + const result = await adminPool.query( + 'INSERT INTO tenant_users (tenant_id, username, password, role, name) VALUES ($1, $2, $3, $4, $5) RETURNING id, tenant_id, username, role, name', + [tenantId, username, hashedPassword, role, name || username] + ) + sendSuccess(res, result.rows[0]) + } catch (err: any) { + if (err.code === '23505') { + return sendError(res, 'User already exists for this tenant') + } + sendError(res, err.message) + } +}) + +// 删除租户用户 +router.delete('/tenants/:tenantId/users/:userId', requirePlatformAdmin, async (req, res) => { + const { tenantId, userId } = req.params + try { + const result = await adminPool.query( + 'DELETE FROM tenant_users WHERE tenant_id = $1 AND id = $2 RETURNING id', + [tenantId, userId] + ) + if (result.rows.length === 0) { + return sendError(res, 'User not found', 404) + } + sendSuccess(res, { deleted: true }) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 平台管理员信息 +router.get('/me', async (req: any, res) => { + if (!req.user) { + return sendError(res, 'Not authenticated', 401) + } + sendSuccess(res, req.user) +}) + +// ============ 租户内用户管理(租户管理员 hq 可用) ============ + +// 租户管理员列出自己租户的用户 +router.get('/tenant/users', async (req: any, res) => { + if (!req.user || !req.user.tenantId) { + return sendError(res, 'Not a tenant user', 403) + } + if (req.user.role !== 'hq') { + return sendError(res, 'Only tenant admin can manage users', 403) + } + try { + const result = await adminPool.query( + 'SELECT id, username, role, name, store_code, region, dept, is_active, created_at FROM tenant_users WHERE tenant_id = $1 ORDER BY id', + [req.user.tenantId] + ) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 租户管理员创建内部用户 +router.post('/tenant/users', async (req: any, res) => { + if (!req.user || !req.user.tenantId) { + return sendError(res, 'Not a tenant user', 403) + } + if (req.user.role !== 'hq') { + return sendError(res, 'Only tenant admin can manage users', 403) + } + const { username, password, role, name, store_code, region, dept } = req.body + if (!username || !password || !role) { + return sendError(res, 'username, password, role are required') + } + const validRoles = ['hq', 'regional', 'store', 'dept'] + if (!validRoles.includes(role)) { + return sendError(res, 'Invalid role') + } + try { + const hashedPassword = await bcrypt.hash(password, 10) + const result = await adminPool.query( + 'INSERT INTO tenant_users (tenant_id, username, password, role, name, store_code, region, dept) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, username, role, name, store_code, region, dept, is_active, created_at', + [req.user.tenantId, username, hashedPassword, role, name || username, store_code || null, region || null, dept || null] + ) + sendSuccess(res, result.rows[0]) + } catch (err: any) { + if (err.code === '23505') { + return sendError(res, '用户名已存在') + } + sendError(res, err.message) + } +}) + +// 租户管理员删除内部用户 +router.delete('/tenant/users/:userId', async (req: any, res) => { + if (!req.user || !req.user.tenantId) { + return sendError(res, 'Not a tenant user', 403) + } + if (req.user.role !== 'hq') { + return sendError(res, 'Only tenant admin can manage users', 403) + } + const { userId } = req.params + try { + const result = await adminPool.query( + 'DELETE FROM tenant_users WHERE tenant_id = $1 AND id = $2 RETURNING id', + [req.user.tenantId, userId] + ) + if (result.rows.length === 0) { + return sendError(res, 'User not found', 404) + } + sendSuccess(res, { deleted: true }) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 租户管理员修改内部用户密码 +router.put('/tenant/users/:userId/password', async (req: any, res) => { + if (!req.user || !req.user.tenantId) { + return sendError(res, 'Not a tenant user', 403) + } + if (req.user.role !== 'hq') { + return sendError(res, 'Only tenant admin can manage users', 403) + } + const { userId } = req.params + const { password } = req.body + if (!password) { + return sendError(res, 'password is required') + } + try { + const hashedPassword = await bcrypt.hash(password, 10) + const result = await adminPool.query( + 'UPDATE tenant_users SET password = $1 WHERE tenant_id = $2 AND id = $3 RETURNING id', + [hashedPassword, req.user.tenantId, userId] + ) + if (result.rows.length === 0) { + return sendError(res, 'User not found', 404) + } + sendSuccess(res, { updated: true }) + } catch (err: any) { + sendError(res, err.message) + } +}) + +export default router diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 27a4e51..35ecadd 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,29 +1,58 @@ import { Router } from 'express' -import { query } from '../config/database.js' +import bcrypt from 'bcrypt' import { sendSuccess, sendError } from '../middleware/error.js' import { generateToken } from '../middleware/auth.js' +import adminPool from '../config/admin-pool.js' import type { AuthUser } from '../types/index.js' const router = Router() -const mockUsers: AuthUser[] = [ - { id: '1', role: 'hq', name: '总部管理员' }, - { id: '2', role: 'regional', name: '区域经理', region: '北京' }, - { id: '3', role: 'store', name: '潘家园店长', storeCode: '0026' }, - { id: '4', role: 'dept', name: '商品部', dept: 'product' }, -] - router.post('/login', async (req, res) => { const { username, password } = req.body if (!username || !password) { return sendError(res, 'Username and password required') } - const user = mockUsers.find((u) => u.name === username || u.id === username) - if (!user) { - return sendError(res, 'Invalid credentials', 401) + + try { + const result = await adminPool.query( + `SELECT tu.id, tu.username, tu.password, tu.role, tu.name, tu.tenant_id, + tu.store_code, tu.region, tu.dept, + tc.status as tenant_status + FROM tenant_users tu + JOIN tenant_configs tc ON tu.tenant_id = tc.tenant_id + WHERE tu.username = $1 AND tu.is_active = true`, + [username] + ) + + if (result.rows.length === 0) { + return sendError(res, '用户名或密码错误', 401) + } + + const row = result.rows[0] + if (row.tenant_status !== 'active') { + return sendError(res, '租户已停用,请联系平台管理员', 403) + } + + const valid = await bcrypt.compare(password, row.password) + if (!valid) { + return sendError(res, '用户名或密码错误', 401) + } + + const user: AuthUser = { + id: String(row.id), + role: row.role, + name: row.name, + tenantId: row.tenant_id, + storeCode: row.store_code || undefined, + region: row.region || undefined, + dept: row.dept || undefined, + } + + const token = generateToken(user) + sendSuccess(res, { token, user }) + } catch (err: any) { + return sendError(res, `登录失败: ${err.message}`) } - const token = generateToken(user) - sendSuccess(res, { token, user }) }) router.get('/me', async (req: any, res) => { diff --git a/server/src/routes/data.ts b/server/src/routes/data.ts index ffc9d23..50bbb7b 100644 --- a/server/src/routes/data.ts +++ b/server/src/routes/data.ts @@ -3,26 +3,48 @@ import { query } from '../config/database.js' import pool from '../config/database.js' import { sendSuccess, sendError, parseMonth, parsePagination, parseDateRange, prevYearMonth, prevMonth } from '../middleware/error.js' import type { AuthRequest } from '../middleware/auth.js' +import { getDataScope, scopeStoreFilter } from '../middleware/data-scope.js' const router = Router() router.get('/overview', async (req: AuthRequest, res) => { try { const month = parseMonth(req) - const result = await query(` - SELECT o.bill_count, o.received, o.avg_bill_value, o.discount_rate_pct, o.theoretical_margin_pct, o.member_bills, o.member_share_pct, - round(b.consumption::numeric, 2) AS consumption, - round(b.discount::numeric, 2) AS discount - FROM analytics.mv_overview_monthly o - LEFT JOIN ( - SELECT round(sum(consumption)::numeric, 2) AS consumption, - round(sum(discount_total)::numeric, 2) AS discount + const scope = getDataScope(req) + + if (scope) { + const { clause, params } = scopeStoreFilter(scope, [month]) + const result = await query(` + SELECT + count(*) AS bill_count, + round(sum(received_total)::numeric, 2) AS received, + round(avg(received_total)::numeric, 2) AS avg_bill_value, + round(avg(CASE WHEN consumption > 0 THEN discount_total::numeric / consumption * 100 ELSE 0 END)::numeric, 2) AS discount_rate_pct, + 0 AS theoretical_margin_pct, + 0 AS member_bills, + 0 AS member_share_pct, + round(sum(consumption)::numeric, 2) AS consumption, + round(sum(discount_total)::numeric, 2) AS discount FROM analytics.bill_fact - WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month') - ) b ON true - WHERE o.month = $1::date - `, [month]) - sendSuccess(res, result.rows[0]) + WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')${clause} + `, params) + sendSuccess(res, result.rows[0]) + } else { + const result = await query(` + SELECT o.bill_count, o.received, o.avg_bill_value, o.discount_rate_pct, o.theoretical_margin_pct, o.member_bills, o.member_share_pct, + round(b.consumption::numeric, 2) AS consumption, + round(b.discount::numeric, 2) AS discount + FROM analytics.mv_overview_monthly o + LEFT JOIN ( + SELECT round(sum(consumption)::numeric, 2) AS consumption, + round(sum(discount_total)::numeric, 2) AS discount + FROM analytics.bill_fact + WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month') + ) b ON true + WHERE o.month = $1::date + `, [month], { skipScope: true }) + sendSuccess(res, result.rows[0]) + } } catch (err: any) { sendError(res, err.message) } @@ -31,8 +53,26 @@ router.get('/overview', async (req: AuthRequest, res) => { router.get('/overview/daily', async (req: AuthRequest, res) => { try { const month = parseMonth(req) - const result = await query(`SELECT business_date, bill_count, received, avg_bill_value, discount_rate_pct FROM analytics.mv_overview_daily WHERE month = $1::date ORDER BY business_date`, [month]) - sendSuccess(res, result.rows) + const scope = getDataScope(req) + + if (scope) { + const { clause, params } = scopeStoreFilter(scope, [month]) + const result = await query(` + SELECT closed_at::date AS business_date, + count(*) AS bill_count, + round(sum(received_total)::numeric, 2) AS received, + round(avg(received_total)::numeric, 2) AS avg_bill_value, + round(avg(CASE WHEN consumption > 0 THEN discount_total::numeric / consumption * 100 ELSE 0 END)::numeric, 2) AS discount_rate_pct + FROM analytics.bill_fact + WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')${clause} + GROUP BY closed_at::date + ORDER BY business_date + `, params) + sendSuccess(res, result.rows) + } else { + const result = await query(`SELECT business_date, bill_count, received, avg_bill_value, discount_rate_pct FROM analytics.mv_overview_daily WHERE month = $1::date ORDER BY business_date`, [month], { skipScope: true }) + sendSuccess(res, result.rows) + } } catch (err: any) { sendError(res, err.message) } diff --git a/server/src/types/index.ts b/server/src/types/index.ts index a47b420..3f9af92 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -1,4 +1,4 @@ -export type UserRole = 'hq' | 'regional' | 'store' | 'dept' +export type UserRole = 'hq' | 'regional' | 'store' | 'dept' | 'platform_admin' export interface AuthUser { id: string @@ -7,6 +7,7 @@ export interface AuthUser { region?: string dept?: string name: string + tenantId?: string } export interface ApiResponse {