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
+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)
}