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:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user