feat: 多租户数据权限 + 数据库连接修正

- adminPool 连服务器本地 5432 sbrain_admin (SCRAM 认证)
- tenantPool/pool 连 FRP 隧道 15432 bill_query (trust 认证)
- query() 自动注入 store_code 数据权限 (store/regional 角色)
- 区域经理 region 字段存储逗号分隔的 store_code 列表
- /overview 和 /overview/daily 对 scoped 用户从 bill_fact 聚合
- tenant_users.region 扩展为 VARCHAR(500)
- deploy.sh .env 增加 ADMIN_DB_* 配置
- run.md 更新数据库连接架构说明
- 新增海淀区区域经理用户 (21 家海淀区门店)
This commit is contained in:
freedakgmail
2026-08-02 20:10:42 +08:00
parent c3fbe4ab81
commit cfa12c9b7f
24 changed files with 2003 additions and 173 deletions
+5 -1
View File
@@ -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() {
) : (
<Layout user={user} onLogout={handleLogout}>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/" element={user?.role === 'platform_admin' ? <Navigate to="/tenant-management" /> : <DashboardPage />} />
<Route path="/boss" element={<BossPage />} />
<Route path="/revenue" element={<RevenuePage />} />
<Route path="/bank" element={<BankPage />} />
@@ -112,6 +114,8 @@ export default function App() {
<Route path="/region-comparison" element={<RegionComparisonPage />} />
<Route path="/employee-performance" element={<EmployeePerformancePage />} />
<Route path="/inventory-turnover" element={<InventoryTurnoverPage />} />
<Route path="/tenant-management" element={<TenantManagementPage />} />
<Route path="/tenant-users" element={<TenantUsersPage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Layout>
+13 -4
View File
@@ -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 */}
<div className="lg:pl-56">
<main className="min-h-screen p-4 pt-16 lg:pt-4">
<div className="mb-4">
<StatusBanner />
</div>
{user?.role !== 'platform_admin' && (
<div className="mb-4">
<StatusBanner />
</div>
)}
{children}
</main>
</div>
@@ -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 <LoadingSpinner text="加载平台数据..." />
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 (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-3">
<MetricCard title="租户总数" value={tenants.length} description="已注册的租户数量" />
<MetricCard title="运行中" value={activeCount} status="good" description="状态为 active 的租户" />
<MetricCard title="已停用" value={inactiveCount} status={inactiveCount > 0 ? 'warn' : undefined} description="状态为 inactive 的租户" />
<MetricCard title="FRP端口数" value={tenants.length} description="已分配的 FRP 隧道端口数量" />
</div>
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="space-y-2">
{tenants.map((t: any) => (
<div key={t.tenant_id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-3">
<span className={cn(
'h-2 w-2 rounded-full',
t.status === 'active' ? 'bg-green-500' : 'bg-gray-400'
)} />
<span className="font-medium text-sm">{t.tenant_name}</span>
<span className="font-mono text-xs text-muted-foreground">{t.tenant_id}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>DB: <span className="font-mono">{t.db_host}:{t.db_port}</span></span>
<span>FRP: <span className="font-mono">{t.frp_port}</span></span>
<span className={cn(
'rounded px-2 py-0.5 font-medium',
t.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
)}>
{t.status === 'active' ? '运行中' : '已停用'}
</span>
</div>
</div>
))}
{tenants.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground"></p>
)}
</div>
</div>
</div>
)
}
+91
View File
@@ -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 (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<MetricCard title="后端服务" value={health.status === 'ok' ? '正常' : '异常'} format="text" status={health.status === 'ok' ? 'good' : 'bad'} description="服务器后端健康状态" />
<MetricCard title="租户总数" value={tenants.length} description="已注册租户数" />
<MetricCard title="活跃租户" value={tenants.filter((t: any) => t.status === 'active').length} status="good" description="运行中的租户数" />
</div>
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="space-y-2">
{tenants.map((t: any) => (
<div key={t.tenant_id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-3">
<span className={cn(
'h-2 w-2 rounded-full',
t.status === 'active' ? 'bg-green-500' : 'bg-gray-400'
)} />
<span className="text-sm font-medium">{t.tenant_name}</span>
<span className="font-mono text-xs text-muted-foreground">{t.tenant_id}</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="font-mono">{t.db_host}:{t.db_port}</span>
<span className={cn(
'rounded px-2 py-0.5 font-medium',
t.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
)}>
{t.status === 'active' ? '运行中' : '已停用'}
</span>
</div>
</div>
))}
{tenants.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground"></p>
)}
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex justify-between rounded-md border px-3 py-2">
<span className="text-muted-foreground"></span>
<span className="font-mono">9333</span>
</div>
<div className="flex justify-between rounded-md border px-3 py-2">
<span className="text-muted-foreground"></span>
<span className="font-mono">sbrain_admin (PostgreSQL 16)</span>
</div>
<div className="flex justify-between rounded-md border px-3 py-2">
<span className="text-muted-foreground">Nginx </span>
<span className="font-mono">/api/ 127.0.0.1:9333</span>
</div>
<div className="flex justify-between rounded-md border px-3 py-2">
<span className="text-muted-foreground">FRP </span>
<span className="font-mono">frps :7000</span>
</div>
<div className="flex justify-between rounded-md border px-3 py-2">
<span className="text-muted-foreground"></span>
<span className="font-mono">pm2</span>
</div>
<div className="flex justify-between rounded-md border px-3 py-2">
<span className="text-muted-foreground"></span>
<span className="font-mono">{health.time ? (health.time as string).substring(0, 19) : '-'}</span>
</div>
</div>
</div>
</div>
)
}
+501
View File
@@ -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<string | null>(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 <LoadingSpinner text="加载租户列表..." />
const users = (tenantDetail as any)?.data?.users || []
return (
<div className="space-y-4">
<div className="flex justify-end">
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground"
>
<Plus size={16} />
</button>
</div>
{/* 租户列表 + 可展开用户管理 */}
<div className="space-y-2">
{tenants.map((t: any) => (
<div key={t.tenant_id} className="rounded-lg border bg-card">
{/* 租户行 */}
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<button
onClick={() => setExpandedTenant(expandedTenant === t.tenant_id ? null : t.tenant_id)}
className="flex items-center gap-2"
>
<span className={cn(
'h-2 w-2 rounded-full',
t.status === 'active' ? 'bg-green-500' : 'bg-gray-400'
)} />
<span className="font-medium text-sm">{t.tenant_name}</span>
<span className="font-mono text-xs text-muted-foreground">{t.tenant_id}</span>
<span className={cn(
'text-xs transition-transform',
expandedTenant === t.tenant_id ? 'rotate-90' : ''
)}></span>
</button>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>DB: <span className="font-mono">{t.db_host}:{t.db_port}/{t.db_name}</span></span>
<span>FRP: <span className="font-mono">{t.frp_port}</span></span>
<span
className={cn(
'cursor-pointer rounded px-2 py-0.5 font-medium',
t.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
)}
onClick={() =>
updateTenantStatus.mutate({
tenantId: t.tenant_id,
status: t.status === 'active' ? 'inactive' : 'active',
})
}
>
{t.status === 'active' ? '运行中' : '已停用'}
</span>
<button
onClick={() => checkStatus.mutate(t.tenant_id)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-blue-600 hover:bg-blue-50"
title="检测数据库连接"
>
<Plug size={12} />
</button>
<button
onClick={() => {
setEditTenantId(t.tenant_id)
setEditForm({
tenant_name: t.tenant_name,
db_host: t.db_host,
db_port: String(t.db_port),
db_name: t.db_name,
db_user: t.db_user,
db_password: '',
frp_port: String(t.frp_port),
})
setShowEdit(true)
}}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-amber-600 hover:bg-amber-50"
title="编辑租户"
>
<Pencil size={12} />
</button>
<button
onClick={() => {
if (confirm(`确认删除租户 ${t.tenant_name}`)) deleteTenant.mutate(t.tenant_id)
}}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-red-600 hover:bg-red-50"
>
<Trash2 size={12} />
</button>
</div>
</div>
{/* 展开的用户管理区 */}
{expandedTenant === t.tenant_id && (
<div className="border-t bg-muted/30 px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xs font-bold text-muted-foreground"></h3>
<button
onClick={() => setShowUserAdd(true)}
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10"
>
<UserPlus size={14} />
</button>
</div>
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{users.map((u: any) => (
<tr key={u.id} className="border-t">
<td className="px-3 py-2 font-medium">{u.username}</td>
<td className="px-3 py-2">{u.name}</td>
<td className="px-3 py-2 text-right">
<button
onClick={() => {
if (confirm(`确认删除用户 ${u.username}`)) deleteUser.mutate({ tenantId: t.tenant_id, userId: u.id })
}}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-red-600 hover:bg-red-50"
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={3} className="px-3 py-4 text-center text-xs text-muted-foreground"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
))}
{tenants.length === 0 && (
<div className="rounded-lg border bg-card p-8 text-center">
<Building2 size={32} className="mx-auto mb-2 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground"></p>
</div>
)}
</div>
{/* 新建租户弹窗 */}
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-96 rounded-lg border bg-card p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-bold"></h2>
<button onClick={() => setShowCreate(false)}>
<X size={16} className="text-muted-foreground" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">ID</label>
<input
value={form.tenant_id}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={form.tenant_name}
onChange={(e) => setForm({ ...form, tenant_name: e.target.value })}
placeholder="如 XX餐饮集团"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={form.db_host}
onChange={(e) => setForm({ ...form, db_host: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={form.db_port}
onChange={(e) => setForm({ ...form, db_port: e.target.value })}
placeholder="如 16002"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={form.db_name}
onChange={(e) => setForm({ ...form, db_name: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">FRP端口</label>
<input
value={form.frp_port}
onChange={(e) => setForm({ ...form, frp_port: e.target.value })}
placeholder="如 16002"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={form.db_user}
onChange={(e) => setForm({ ...form, db_user: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={form.db_password}
onChange={(e) => setForm({ ...form, db_password: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
</div>
{createTenant.isError && (
<p className="text-xs text-red-600">{(createTenant.error as any)?.data?.error || '创建失败'}</p>
)}
<button
onClick={() => createTenant.mutate(form)}
disabled={!form.tenant_id || !form.tenant_name || !form.db_port || !form.frp_port}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
{/* 添加用户弹窗 */}
{showUserAdd && expandedTenant && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-80 rounded-lg border bg-card p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-bold"></h2>
<button onClick={() => setShowUserAdd(false)}>
<X size={16} className="text-muted-foreground" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={userForm.username}
onChange={(e) => setUserForm({ ...userForm, username: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={userForm.password}
onChange={(e) => setUserForm({ ...userForm, password: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={userForm.name}
onChange={(e) => setUserForm({ ...userForm, name: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
{addUser.isError && (
<p className="text-xs text-red-600">{(addUser.error as any)?.data?.error || '添加失败'}</p>
)}
<button
onClick={() => addUser.mutate({ tenantId: expandedTenant, data: { ...userForm, role: 'hq' } })}
disabled={!userForm.username || !userForm.password}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
{/* 编辑租户弹窗 */}
{showEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-96 rounded-lg border bg-card p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-bold"></h2>
<button onClick={() => setShowEdit(false)}>
<X size={16} className="text-muted-foreground" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.tenant_name}
onChange={(e) => setEditForm({ ...editForm, tenant_name: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.db_host}
onChange={(e) => setEditForm({ ...editForm, db_host: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.db_port}
onChange={(e) => setEditForm({ ...editForm, db_port: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.db_name}
onChange={(e) => setEditForm({ ...editForm, db_name: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">FRP端口</label>
<input
value={editForm.frp_port}
onChange={(e) => setEditForm({ ...editForm, frp_port: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.db_user}
onChange={(e) => setEditForm({ ...editForm, db_user: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.db_password}
onChange={(e) => setEditForm({ ...editForm, db_password: e.target.value })}
placeholder="留空不修改"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
</div>
{updateTenant.isError && (
<p className="text-xs text-red-600">{(updateTenant.error as any)?.data?.error || '更新失败'}</p>
)}
<button
onClick={() => {
const data: any = { ...editForm, db_port: Number(editForm.db_port), frp_port: Number(editForm.frp_port) }
if (!data.db_password) delete data.db_password
updateTenant.mutate({ tenantId: editTenantId, data })
}}
disabled={!editForm.tenant_name}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}
+6 -4
View File
@@ -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) {
</button>
<p className="text-center text-xs text-muted-foreground">
/ / /
·
</p>
</div>
</div>
+58
View File
@@ -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 (
<div className="space-y-4">
<div>
<h1 className="flex items-center gap-2 text-xl font-bold">
<Shield size={22} className="text-primary" />
</h1>
<p className="mt-0.5 text-xs text-muted-foreground">SaaS · · · </p>
</div>
{/* Tab 切换 */}
<div className="flex gap-1 border-b">
{TABS.map((t) => {
const Icon = t.icon
const active = tab === t.key
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={cn(
'flex items-center gap-1.5 border-b-2 px-4 py-2 text-sm font-medium transition-colors',
active ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
<Icon size={16} />
{t.label}
</button>
)
})}
</div>
{tab === 'overview' && <OverviewTab />}
{tab === 'tenants' && <TenantsTab />}
{tab === 'system' && <SystemTab />}
</div>
)
}
// Tab 组件拆分至 @/components/admin/ 目录:
// - OverviewTab.tsx 平台概览
// - TenantsTab.tsx 租户管理(含用户管理)
// - SystemTab.tsx 系统监控
+275
View File
@@ -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<string, string> = {
hq: '总部管理员',
regional: '区域经理',
store: '店长',
dept: '商品部',
}
export function TenantUsersPage() {
const qc = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [editUserId, setEditUserId] = useState<number | null>(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 <LoadingSpinner text="加载用户列表..." />
const users = (data as any)?.data || []
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-xl font-bold">
<Users size={22} className="text-primary" />
</h1>
<p className="mt-0.5 text-xs text-muted-foreground"></p>
</div>
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground"
>
<UserPlus size={16} />
</button>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{users.map((u: any) => (
<tr key={u.id} className="border-t">
<td className="px-4 py-2 font-medium">{u.username}</td>
<td className="px-4 py-2">{u.name}</td>
<td className="px-4 py-2">
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
{ROLE_LABELS[u.role] || u.role}
</span>
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
{u.store_code && <span className="mr-1">:{u.store_code}</span>}
{u.region && <span className="mr-1">:{u.region}</span>}
{u.dept && <span>:{u.dept}</span>}
{!u.store_code && !u.region && !u.dept && '-'}
</td>
<td className="px-4 py-2">
<span className={u.is_active ? 'text-green-600' : 'text-gray-400'}>
{u.is_active ? '启用' : '禁用'}
</span>
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
{u.created_at ? String(u.created_at).substring(0, 10) : '-'}
</td>
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => {
setEditUserId(u.id)
setShowPassword(true)
setPasswordForm('')
}}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-amber-600 hover:bg-amber-50"
title="修改密码"
>
<KeyRound size={12} />
</button>
<button
onClick={() => {
if (confirm(`确认删除用户 ${u.username}`)) deleteUser.mutate(u.id)
}}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-red-600 hover:bg-red-50"
>
<Trash2 size={12} />
</button>
</div>
</td>
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-xs text-muted-foreground"></td>
</tr>
)}
</tbody>
</table>
</div>
{/* 添加用户弹窗 */}
{showAdd && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-80 rounded-lg border bg-card p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-bold"></h2>
<button onClick={() => setShowAdd(false)}>
<X size={16} className="text-muted-foreground" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.username}
onChange={(e) => setAddForm({ ...addForm, username: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.password}
onChange={(e) => setAddForm({ ...addForm, password: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<select
value={addForm.role}
onChange={(e) => setAddForm({ ...addForm, role: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
>
<option value="regional"></option>
<option value="store"></option>
<option value="dept"></option>
</select>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.name}
onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
{addForm.role === 'store' && (
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.store_code}
onChange={(e) => setAddForm({ ...addForm, store_code: e.target.value })}
placeholder="如:pjy"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
)}
{addForm.role === 'regional' && (
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.region}
onChange={(e) => 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"
/>
</div>
)}
{addForm.role === 'dept' && (
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.dept}
onChange={(e) => setAddForm({ ...addForm, dept: e.target.value })}
placeholder="如:商品部"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
)}
{addUser.isError && (
<p className="text-xs text-red-600">{(addUser.error as any)?.data?.error || '添加失败'}</p>
)}
<button
onClick={() => addUser.mutate(addForm)}
disabled={!addForm.username || !addForm.password}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
{/* 修改密码弹窗 */}
{showPassword && editUserId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-72 rounded-lg border bg-card p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-bold"></h2>
<button onClick={() => { setShowPassword(false); setEditUserId(null) }}>
<X size={16} className="text-muted-foreground" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={passwordForm}
onChange={(e) => setPasswordForm(e.target.value)}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
{updatePassword.isError && (
<p className="text-xs text-red-600">{(updatePassword.error as any)?.data?.error || '修改失败'}</p>
)}
<button
onClick={() => updatePassword.mutate({ userId: editUserId, password: passwordForm })}
disabled={!passwordForm}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}
-73
View File
@@ -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 "=========================================="
+126 -28
View File
@@ -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
+73 -27
View File
@@ -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 隧道**:暴露 PostgreSQL5432→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` 为 NULLtrust 认证)。
> ⚠️ `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` 在服务器本地 PostgreSQL5432**,业务库 `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` 为 NULLtrust 认证)
- `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
+46
View File
@@ -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",
+2
View File
@@ -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",
+39
View File
@@ -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;
+24
View File
@@ -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
+54 -7
View File
@@ -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<T = any> {
rowCount: number | null
}
export async function query<T = any>(text: string, params?: any[]): Promise<QueryResult<T>> {
export async function query<T = any>(text: string, params?: any[], opts?: { skipScope?: boolean }): Promise<QueryResult<T>> {
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<T>(callback: (client: pg.PoolClient) => Promise<T>): Promise<T> {
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)
+74
View File
@@ -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<TenantContext>()
const poolCache = new Map<string, { pool: pg.Pool; lastUsed: number }>()
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)
+18
View File
@@ -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)
+56
View File
@@ -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 }
}
+46
View File
@@ -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}`)
}
}
+337
View File
@@ -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
+42 -13
View File
@@ -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) => {
+55 -15
View File
@@ -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)
}
+2 -1
View File
@@ -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<T = any> {