fix: 角色权限与数据范围修复

- 添加 RoleRoute 路由守卫组件,防止越权访问页面
- 菜单配置:区域经理加'区域对比',店长加'态势感知'
- database.ts: scope注入时检测主表别名避免store_code歧义
- analytics-enhanced.ts: KPI region模式手动注入scope过滤
- situational-awareness.ts: health-score手动注入scope,alerts加skipScope
- smart-scheduling.ts: position-salary-compare店长角色降低HAVING阈值
This commit is contained in:
freedakgmail
2026-08-02 21:28:25 +08:00
parent cfa12c9b7f
commit a011729fc9
13 changed files with 474 additions and 110 deletions
+59 -26
View File
@@ -34,35 +34,68 @@ export async function query<T = any>(text: string, params?: any[], opts?: { skip
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 scopeCodes = ctx.scope.role === 'store'
? ctx.scope.storeCode!.split(',').map(s => s.trim()).filter(Boolean)
: ctx.scope.region!.split(',').map(s => s.trim()).filter(Boolean)
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 (scopeCodes.length > 0) {
// Handle raw import tables with non-standard store columns
const hasBillRecords = /\bbill_records\b/.test(sqlLower)
const hasSalaryRecords = /\bsalary_detail_records\b/.test(sqlLower)
const hasAttendanceRecords = /\battendance_records\b/.test(sqlLower)
const rawTableCount = [hasBillRecords, hasSalaryRecords, hasAttendanceRecords].filter(Boolean).length
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`)
let scopeClause: string | null = null
if (rawTableCount === 1 && hasBillRecords) {
scopeClause = ` c003 IN (SELECT store_name FROM analytics.dim_store WHERE store_code = ANY($${sqlParams.length + 1}))`
} else if (rawTableCount === 1 && hasSalaryRecords) {
scopeClause = ` org_level5 IN (SELECT COALESCE(m.salary_name, d.store_name) FROM analytics.dim_store d LEFT JOIN store_name_mapping m ON m.bill_name = d.store_name WHERE d.store_code = ANY($${sqlParams.length + 1}))`
} else if (rawTableCount === 1 && hasAttendanceRecords) {
scopeClause = ` EXISTS (SELECT 1 FROM analytics.dim_store d WHERE d.store_code = ANY($${sqlParams.length + 1}) AND ${/\bar\b/.test(sqlLower) ? 'ar' : 'attendance_records'}.department LIKE '%' || d.store_name || '%')`
} else if (rawTableCount >= 2 && hasSalaryRecords) {
// Multiple raw tables with salary_detail_records as main table (e.g. attendance-alert)
scopeClause = ` s.org_level5 IN (SELECT COALESCE(m.salary_name, d.store_name) FROM analytics.dim_store d LEFT JOIN store_name_mapping m ON m.bill_name = d.store_name WHERE d.store_code = ANY($${sqlParams.length + 1}))`
} else {
sql = sql + ` WHERE${scopeClause}`
// For normal views/tables with store_code or store_name
const hasStoreCode = /\bstore_code\b/.test(sqlLower)
const hasStoreName = /\bstore_name\b/.test(sqlLower)
const hasStoreRef = hasStoreCode || hasStoreName || /v_store_|mv_store_|bill_fact|fact_bill|store_task|dim_store/.test(sqlLower)
if (hasStoreRef) {
// Detect main table alias to avoid ambiguous column reference
// Look for "FROM <table> <alias>" pattern where alias is a short identifier
const fromMatch = sql.match(/\bFROM\s+[\w.]+\s+(?:AS\s+)?(\w+)/i)
const mainAlias = fromMatch && !['where', 'group', 'order', 'left', 'right', 'inner', 'join', 'on', 'and', 'or', 'select', 'from', 'having', 'limit', 'union'].includes(fromMatch[1].toLowerCase()) ? fromMatch[1] : null
const colPrefix = mainAlias ? `${mainAlias}.` : ''
scopeClause = hasStoreCode
? ` ${colPrefix}store_code = ANY($${sqlParams.length + 1})`
: ` ${colPrefix}store_name IN (SELECT store_name FROM analytics.dim_store WHERE store_code = ANY($${sqlParams.length + 1}))`
}
}
if (scopeClause) {
sqlParams = [...sqlParams, scopeCodes]
// Inject scope filter into the main WHERE clause, skipping WHERE inside FILTER(WHERE ...) and subqueries
// Strategy: mask FILTER(WHERE), then replace the LAST WHERE (main query's WHERE comes after subquery WHEREs)
const maskToken = '__FWHM__'
const maskedSql = sql.replace(/FILTER\s*\(\s*WHERE\b/g, `FILTER (${maskToken}`)
const whereMatches = [...maskedSql.matchAll(/\bWHERE\b/g)]
if (whereMatches.length > 0) {
// Replace the last WHERE occurrence (main query WHERE is typically last)
const lastWhere = whereMatches[whereMatches.length - 1]
sql = maskedSql.substring(0, lastWhere.index!) + `WHERE${scopeClause} AND` + maskedSql.substring(lastWhere.index! + 5)
sql = sql.replace(new RegExp(maskToken, 'g'), 'WHERE')
} else {
// No WHERE clause: inject after the last FROM <table> in the main (non-CTE) query
const fromMatches = [...maskedSql.matchAll(/\bFROM\s+(\w+)/g)]
if (fromMatches.length > 0) {
const lastFrom = fromMatches[fromMatches.length - 1]
const insertPos = lastFrom.index! + lastFrom[0].length
sql = maskedSql.substring(0, insertPos) + ` WHERE${scopeClause}` + maskedSql.substring(insertPos)
} else {
sql = sql + ` WHERE${scopeClause}`
}
sql = sql.replace(new RegExp(maskToken, 'g'), 'WHERE')
}
}
}
}
+4 -2
View File
@@ -35,10 +35,12 @@ export function scopeStoreFilter(scope: DataScope | null, existingParams: any[])
if (!scope) return { clause: '', params: existingParams }
if (scope.role === 'store' && scope.storeCode) {
const codes = scope.storeCode.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 = $${idx}`,
params: [...existingParams, scope.storeCode],
clause: ` AND store_code = ANY($${idx})`,
params: [...existingParams, codes],
}
}
+43
View File
@@ -4,6 +4,7 @@ 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 { query } from '../config/database.js'
import type { AuthUser } from '../types/index.js'
const { Pool } = pg
@@ -334,4 +335,46 @@ router.put('/tenant/users/:userId/password', async (req: any, res) => {
}
})
// 租户管理员更新内部用户信息(关联门店等)
router.put('/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
const { store_code, region, dept } = req.body
try {
const result = await adminPool.query(
'UPDATE tenant_users SET store_code = $1, region = $2, dept = $3 WHERE tenant_id = $4 AND id = $5 RETURNING id, username, role, name, store_code, region, dept, is_active, created_at',
[store_code || null, region || null, dept || null, req.user.tenantId, userId]
)
if (result.rows.length === 0) {
return sendError(res, 'User not found', 404)
}
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
// 门店列表(含位置信息,供用户管理选择)
router.get('/tenant/stores', async (req: any, res) => {
if (!req.user || !req.user.tenantId) {
return sendError(res, 'Not a tenant user', 403)
}
try {
const result = await query(`
SELECT store_code, store_name, region, business_type, business_area
FROM analytics.dim_store
WHERE close_date IS NULL OR close_date > NOW()
ORDER BY store_code
`, [], { skipScope: true })
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
export default router
+13 -3
View File
@@ -2,6 +2,7 @@ import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError, parseMonth, parsePagination } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
import { getDataScope } from '../middleware/data-scope.js'
const router = Router()
@@ -763,6 +764,15 @@ router.get('/kpi', async (req: AuthRequest, res) => {
sendSuccess(res, result.rows[0] || {})
} else if (level === 'region') {
// 按区域汇总KPI
const scope = getDataScope(req)
const scopeCodes = scope?.role === 'store' || scope?.role === 'regional'
? (scope.storeCode || scope.region || '').split(',').map(s => s.trim()).filter(Boolean)
: []
const hasScope = scopeCodes.length > 0
const scopeParamIdx = hasScope ? 2 : 0
const scopeParams = hasScope ? [month, scopeCodes] : [month]
const scopeFilter = hasScope ? ` AND r.store_code = ANY($${scopeParamIdx})` : ''
const scopeFilterTarget = hasScope ? ` AND t.store_code = ANY($${scopeParamIdx})` : ''
const result = await query(`
WITH actual AS (
SELECT
@@ -774,7 +784,7 @@ router.get('/kpi', async (req: AuthRequest, res) => {
JOIN analytics.dim_store ds ON r.store_code = ds.store_code
LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1
WHERE r.month_start = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
AND ds.region IS NOT NULL AND ds.region != '未知区域'${scopeFilter}
GROUP BY ds.region
),
target AS (
@@ -786,7 +796,7 @@ router.get('/kpi', async (req: AuthRequest, res) => {
FROM analytics.dim_store_target t
JOIN analytics.dim_store ds ON t.store_code = ds.store_code
WHERE t.target_month = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
AND ds.region IS NOT NULL AND ds.region != '未知区域'${scopeFilterTarget}
GROUP BY ds.region
)
SELECT
@@ -807,7 +817,7 @@ router.get('/kpi', async (req: AuthRequest, res) => {
FROM actual a
LEFT JOIN target t ON a.region = t.region
ORDER BY a.actual_revenue DESC
`, [month])
`, scopeParams, { skipScope: true })
sendSuccess(res, result.rows)
} else {
// 总部汇总KPI
+4 -2
View File
@@ -734,6 +734,8 @@ router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => {
router.get('/region/summary', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const scope = getDataScope(req)
const { clause, params } = scopeStoreFilter(scope, [month])
const result = await query(`
SELECT d.region,
count(DISTINCT b.store_code) AS store_count,
@@ -752,10 +754,10 @@ router.get('/region/summary', async (req: AuthRequest, res) => {
JOIN analytics.dim_store d ON d.store_code = b.store_code
LEFT JOIN analytics.mv_store_risk_rating_monthly r ON r.store_code = b.store_code AND r.month_start = $1::date
WHERE d.region IS NOT NULL AND d.region <> ''
AND b.closed_at >= $1::date AND b.closed_at < ($1::date + interval '1 month')
AND b.closed_at >= $1::date AND b.closed_at < ($1::date + interval '1 month')${clause.replace(' AND store_code', ' AND b.store_code')}
GROUP BY d.region
ORDER BY total_received DESC
`, [month])
`, params, { skipScope: true })
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
+12 -4
View File
@@ -2,6 +2,7 @@ import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError, parseMonth } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
import { getDataScope } from '../middleware/data-scope.js'
const router = Router()
@@ -10,13 +11,20 @@ const router = Router()
router.get('/health-score', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const scope = getDataScope(req)
const scopeCodes = scope?.role === 'store' || scope?.role === 'regional'
? (scope.storeCode || scope.region || '').split(',').map(s => s.trim()).filter(Boolean)
: []
const hasScope = scopeCodes.length > 0
const scopeParams = hasScope ? [month, scopeCodes] : [month]
const scopeFilter = hasScope ? ` AND store_code = ANY($2)` : ''
const result = await query(`
WITH base AS (
SELECT store_code, store_name, received, bill_count,
avg_bill_value, avg_daily_received, theoretical_margin_pct,
member_bill_share_pct, risk_level
FROM analytics.mv_store_risk_rating_monthly
WHERE month_start = $1 AND received IS NOT NULL
WHERE month_start = $1 AND received IS NOT NULL${scopeFilter}
),
cost AS (
SELECT store_code,
@@ -93,7 +101,7 @@ router.get('/health-score', async (req: AuthRequest, res) => {
END AS health_status
FROM scored
ORDER BY health_score DESC
`, [month])
`, scopeParams, { skipScope: true })
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -129,7 +137,7 @@ router.get('/alerts', async (req: AuthRequest, res) => {
WHERE d.received < m.avg_received * 0.7
ORDER BY d.business_date DESC
LIMIT 10
`)
`, [], { skipScope: true })
revenueAlerts.rows.forEach((r: any) => {
alerts.push({
type: 'revenue',
@@ -153,7 +161,7 @@ router.get('/alerts', async (req: AuthRequest, res) => {
AND (cost_variance_amount / theoretical_cost) > 0.3
ORDER BY variance_pct DESC
LIMIT 10
`)
`, [], { skipScope: true })
costAlerts.rows.forEach((r: any) => {
alerts.push({
type: 'cost',
+3 -2
View File
@@ -776,6 +776,7 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => {
const month = parseMonth(req)
const [yr, mo] = month.split('-')
const periodLabel = `${parseInt(yr)}${parseInt(mo)}`
const minCount = req.user?.role === 'store' ? 1 : 5
const result = await query(`
SELECT position,
count(*) AS emp_count,
@@ -792,9 +793,9 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => {
AND position IS NOT NULL AND position != ''
AND salary_period = $1
GROUP BY position
HAVING count(*) >= 5
HAVING count(*) >= $2
ORDER BY avg_gross DESC
`, [periodLabel])
`, [periodLabel, minCount])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)