feat: 平台管理员端 — SUPER_ADMIN 角色 + 企业租户管理 + 用户管理 + 数据总览
This commit is contained in:
@@ -57,6 +57,7 @@ import attendanceRoutes from './routes/attendance.routes'
|
||||
import templateRoutes from './routes/template.routes'
|
||||
import auditRoutes from './routes/audit.routes'
|
||||
import calendarRoutes from './routes/calendar.routes'
|
||||
import platformRoutes from './routes/platform.routes'
|
||||
app.use('/api/v1/auth', authRoutes)
|
||||
app.use('/api/v1/dashboard', dashboardRoutes)
|
||||
app.use('/api/v1/employees', employeeRoutes)
|
||||
@@ -78,6 +79,7 @@ app.use('/api/v1/attendance', attendanceRoutes)
|
||||
app.use('/api/v1/templates', templateRoutes)
|
||||
app.use('/api/v1/audit', auditRoutes)
|
||||
app.use('/api/v1/calendar', calendarRoutes)
|
||||
app.use('/api/v1/platform', platformRoutes)
|
||||
|
||||
app.use(errorHandler)
|
||||
|
||||
|
||||
+12
-6
@@ -3,25 +3,31 @@ import jwt from 'jsonwebtoken'
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret'
|
||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret'
|
||||
|
||||
export function signAccessToken(payload: { id: string; orgId: string; role: string }): string {
|
||||
export interface JwtPayload {
|
||||
id: string
|
||||
orgId: string | null
|
||||
role: string
|
||||
}
|
||||
|
||||
export function signAccessToken(payload: JwtPayload): string {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: '2h' })
|
||||
}
|
||||
|
||||
export function signRefreshToken(payload: { id: string; orgId: string; role: string }): string {
|
||||
export function signRefreshToken(payload: JwtPayload): string {
|
||||
return jwt.sign(payload, JWT_REFRESH_SECRET, { expiresIn: '7d' })
|
||||
}
|
||||
|
||||
export function verifyAccessToken(token: string): { id: string; orgId: string; role: string } | null {
|
||||
export function verifyAccessToken(token: string): JwtPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET) as { id: string; orgId: string; role: string }
|
||||
return jwt.verify(token, JWT_SECRET) as JwtPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyRefreshToken(token: string): { id: string; orgId: string; role: string } | null {
|
||||
export function verifyRefreshToken(token: string): JwtPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, JWT_REFRESH_SECRET) as { id: string; orgId: string; role: string }
|
||||
return jwt.verify(token, JWT_REFRESH_SECRET) as JwtPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Request, Response, NextFunction } from 'express'
|
||||
import { verifyAccessToken } from '../lib/jwt'
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: { id: string; orgId: string; role: string }
|
||||
orgId?: string
|
||||
user?: { id: string; orgId: string | null; role: string }
|
||||
orgId?: string | null
|
||||
}
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
@@ -26,3 +26,13 @@ export function orgFilterMiddleware(req: AuthRequest, _res: Response, next: Next
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台管理员鉴权中间件,仅允许 SUPER_ADMIN 角色通过
|
||||
*/
|
||||
export function platformAdminMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
if (!req.user || req.user.role !== 'SUPER_ADMIN') {
|
||||
return res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: '需要平台管理员权限' } })
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { registerSchema, loginSchema, refreshSchema, resetPasswordSchema, forgotPasswordSchema, verifyCodeSchema } from '../schemas/auth.schema'
|
||||
import { register, login, refresh, resetPassword } from '../services/auth.service'
|
||||
import { register, login, refresh, resetPassword, platformLogin } from '../services/auth.service'
|
||||
import { authLimiter, loginLimiter } from '../middleware/rateLimit'
|
||||
import prisma from '../lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
@@ -28,6 +28,16 @@ router.post('/login', loginLimiter, async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/platform-login', loginLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = loginSchema.parse(req.body)
|
||||
const result = await platformLogin(data.phone, data.password)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/refresh', async (req, res, next) => {
|
||||
try {
|
||||
const data = refreshSchema.parse(req.body)
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 平台管理员路由
|
||||
* 管理所有企业租户、用户、数据总览
|
||||
* 所有接口需要 SUPER_ADMIN 权限
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { AuthRequest, authMiddleware, platformAdminMiddleware } from '../middleware/auth'
|
||||
import { loginLimiter } from '../middleware/rateLimit'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 所有平台路由都需要认证 + SUPER_ADMIN 权限
|
||||
router.use(authMiddleware, platformAdminMiddleware)
|
||||
|
||||
// ========== 数据总览 ==========
|
||||
|
||||
/**
|
||||
* 平台总览数据
|
||||
*/
|
||||
router.get('/dashboard', async (_req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const [orgs, users, employees, contracts, payslips] = await Promise.all([
|
||||
prisma.organization.count(),
|
||||
prisma.user.count({ where: { role: { not: 'SUPER_ADMIN' } } }),
|
||||
prisma.employee.count(),
|
||||
prisma.laborContract.count(),
|
||||
prisma.payslip.count(),
|
||||
])
|
||||
|
||||
// 按套餐分组
|
||||
const orgsByPlan = await prisma.organization.groupBy({
|
||||
by: ['plan'],
|
||||
_count: true,
|
||||
})
|
||||
|
||||
// 最近 7 天注册的企业
|
||||
const recentOrgs = await prisma.organization.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true, name: true, plan: true, city: true,
|
||||
createdAt: true, maxEmployees: true,
|
||||
_count: { select: { employees: true, users: true } },
|
||||
},
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalOrgs: orgs,
|
||||
totalUsers: users,
|
||||
totalEmployees: employees,
|
||||
totalContracts: contracts,
|
||||
totalPayslips: payslips,
|
||||
orgsByPlan: orgsByPlan.map((g: any) => ({ plan: g.plan, count: g._count })),
|
||||
recentOrgs: recentOrgs.map((o: any) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
plan: o.plan,
|
||||
city: o.city,
|
||||
createdAt: o.createdAt,
|
||||
maxEmployees: o.maxEmployees,
|
||||
employeeCount: o._count.employees,
|
||||
userCount: o._count.users,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 企业租户管理 ==========
|
||||
|
||||
/**
|
||||
* 企业列表(分页 + 搜索)
|
||||
*/
|
||||
router.get('/orgs', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const search = (req.query.search as string) || ''
|
||||
const planFilter = (req.query.plan as string) || ''
|
||||
|
||||
const where: any = {}
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ city: { contains: search, mode: 'insensitive' } },
|
||||
{ contactName: { contains: search, mode: 'insensitive' } },
|
||||
{ contactPhone: { contains: search } },
|
||||
]
|
||||
}
|
||||
if (planFilter) {
|
||||
where.plan = planFilter
|
||||
}
|
||||
|
||||
const [orgs, total] = await Promise.all([
|
||||
prisma.organization.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true, name: true, plan: true, maxEmployees: true,
|
||||
city: true, contactName: true, contactPhone: true,
|
||||
payrollFrequency: true, retirementReminderEnabled: true,
|
||||
createdAt: true, updatedAt: true,
|
||||
_count: {
|
||||
select: { employees: true, users: true, contracts: true, payslips: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.organization.count({ where }),
|
||||
])
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: orgs.map((o: any) => ({
|
||||
...o,
|
||||
employeeCount: o._count.employees,
|
||||
userCount: o._count.users,
|
||||
contractCount: o._count.contracts,
|
||||
payslipCount: o._count.payslips,
|
||||
_count: undefined,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 企业详情
|
||||
*/
|
||||
router.get('/orgs/:id', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { employees: true, users: true, contracts: true, payslips: true },
|
||||
},
|
||||
users: {
|
||||
select: { id: true, name: true, phone: true, role: true, disabled: true, lastLoginAt: true, createdAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!org) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '企业不存在' } })
|
||||
}
|
||||
|
||||
res.json({ success: true, data: org })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新企业信息(套餐、员工上限等)
|
||||
*/
|
||||
router.put('/orgs/:id', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { name, plan, maxEmployees, city, contactName, contactPhone } = req.body as {
|
||||
name?: string; plan?: string; maxEmployees?: number;
|
||||
city?: string; contactName?: string; contactPhone?: string
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
if (name) updateData.name = name
|
||||
if (plan) updateData.plan = plan
|
||||
if (maxEmployees !== undefined) updateData.maxEmployees = maxEmployees
|
||||
if (city !== undefined) updateData.city = city
|
||||
if (contactName !== undefined) updateData.contactName = contactName
|
||||
if (contactPhone !== undefined) updateData.contactPhone = contactPhone
|
||||
|
||||
const org = await prisma.organization.update({
|
||||
where: { id: req.params.id },
|
||||
data: updateData,
|
||||
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: org })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除企业(级联删除所有数据)
|
||||
*/
|
||||
router.delete('/orgs/:id', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
await prisma.organization.delete({ where: { id: req.params.id } })
|
||||
res.json({ success: true, data: { message: '企业已删除' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 用户管理 ==========
|
||||
|
||||
/**
|
||||
* 所有企业用户列表(分页 + 搜索)
|
||||
*/
|
||||
router.get('/users', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const search = (req.query.search as string) || ''
|
||||
const orgId = (req.query.orgId as string) || ''
|
||||
|
||||
const where: any = { role: { not: 'SUPER_ADMIN' } }
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search } },
|
||||
]
|
||||
}
|
||||
if (orgId) {
|
||||
where.orgId = orgId
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true, name: true, phone: true, role: true,
|
||||
disabled: true, lastLoginAt: true, createdAt: true,
|
||||
org: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
])
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: users.map((u: any) => ({
|
||||
...u,
|
||||
orgName: u.org?.name || null,
|
||||
org: undefined,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 启用/禁用用户
|
||||
*/
|
||||
router.put('/users/:id/toggle', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.params.id } })
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '用户不存在' } })
|
||||
}
|
||||
if (user.role === 'SUPER_ADMIN') {
|
||||
return res.status(400).json({ success: false, error: { code: 'FORBIDDEN', message: '不能操作平台管理员账号' } })
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: { disabled: !user.disabled },
|
||||
select: { id: true, name: true, phone: true, role: true, disabled: true },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 平台管理员管理 ==========
|
||||
|
||||
/**
|
||||
* 平台管理员列表
|
||||
*/
|
||||
router.get('/admins', async (_req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const admins = await prisma.user.findMany({
|
||||
where: { role: 'SUPER_ADMIN' },
|
||||
select: { id: true, name: true, phone: true, disabled: true, lastLoginAt: true, createdAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: admins })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 创建平台管理员
|
||||
*/
|
||||
router.post('/admins', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { name, phone, password } = req.body as { name: string; phone: string; password: string }
|
||||
|
||||
if (!name || !phone || !password) {
|
||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION', message: '姓名、手机号、密码不能为空' } })
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { phone } })
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, error: { code: 'DUPLICATE', message: '该手机号已存在' } })
|
||||
}
|
||||
|
||||
const bcrypt = await import('bcryptjs')
|
||||
const passwordHash = await bcrypt.default.hash(password, 10)
|
||||
const admin = await prisma.user.create({
|
||||
data: { name, phone, passwordHash, role: 'SUPER_ADMIN', orgId: null },
|
||||
select: { id: true, name: true, phone: true, role: true },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: admin })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -42,6 +42,43 @@ export async function register(orgName: string, phone: string, password: string)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台管理员登录(仅 SUPER_ADMIN 角色可登录平台端)
|
||||
*/
|
||||
export async function platformLogin(phone: string, password: string) {
|
||||
const user = await prisma.user.findUnique({ where: { phone } })
|
||||
if (!user) {
|
||||
throw { code: 'NOT_FOUND', message: '手机号或密码错误' }
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
throw { code: 'AUTH_FAILED', message: '手机号或密码错误' }
|
||||
}
|
||||
|
||||
if (user.role !== 'SUPER_ADMIN') {
|
||||
throw { code: 'FORBIDDEN', message: '该账号无平台管理权限' }
|
||||
}
|
||||
|
||||
if (user.disabled) {
|
||||
throw { code: 'ACCOUNT_DISABLED', message: '该账号已被禁用' }
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
})
|
||||
|
||||
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
const refreshToken = signRefreshToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
|
||||
return {
|
||||
user: { id: user.id, orgId: user.orgId, name: user.name, phone: user.phone, role: user.role },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(phone: string, password: string) {
|
||||
const user = await prisma.user.findUnique({ where: { phone } })
|
||||
if (!user) {
|
||||
|
||||
Reference in New Issue
Block a user