feat: 全模块公司隔离 + crossDomain权限码动态跨域控制

- get_current_company_filter: 新增_has_cross_domain_permission, 权限码替代硬编码
- 补全11个Service的get_current_company_filter调用(semi/product/service/outbound/bom/trans/scrap/summary)
- base/search修复: search_material此前无隔离, 已补全
- get_current_company_filter兜底: JWT缺company_name时返回__NO_COMPANY__防止放行
- permission.py: _get_operator_company补全返回值, 修复权限页保存逻辑
- 新增crossDomain迁移脚本, element_type=element挂system_mgmt下
This commit is contained in:
yueli
2026-07-15 11:11:40 +08:00
parent 56edb92d80
commit e1417d740a
14 changed files with 186 additions and 14 deletions

View File

@ -14,6 +14,7 @@ def get_list():
start_date = request.args.get('start_date') start_date = request.args.get('start_date')
end_date = request.args.get('end_date') end_date = request.args.get('end_date')
source_type = request.args.get('source_type') # 可选:筛选 specific table source_type = request.args.get('source_type') # 可选:筛选 specific table
company = request.args.get('company', '')
result = InboundSummaryService.get_list( result = InboundSummaryService.get_list(
page=page, page=page,
@ -21,7 +22,8 @@ def get_list():
keyword=keyword, keyword=keyword,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
source_type=source_type source_type=source_type,
company=company
) )
return jsonify({ return jsonify({
@ -47,13 +49,15 @@ def export_data():
start_date = request.args.get('start_date') start_date = request.args.get('start_date')
end_date = request.args.get('end_date') end_date = request.args.get('end_date')
source_type = request.args.get('source_type') source_type = request.args.get('source_type')
company = request.args.get('company', '')
# 调用导出服务 # 调用导出服务
file_stream = InboundSummaryService.export_excel( file_stream = InboundSummaryService.export_excel(
keyword=keyword, keyword=keyword,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
source_type=source_type source_type=source_type,
company=company
) )
from datetime import datetime from datetime import datetime

View File

@ -179,10 +179,10 @@ def get_outbound_list():
limit = int(request.args.get('limit', 10)) limit = int(request.args.get('limit', 10))
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all') search_type = request.args.get('search_type', 'all')
# 如果前端传了日期范围,可以解析处理,这里暂略 company = request.args.get('company', '')
# ★ [修改] 调用分组查询服务,支持搜索类型 # ★ [修改] 调用分组查询服务,支持搜索类型
result = OutboundService.get_grouped_list(page, limit, keyword, search_type=search_type) result = OutboundService.get_grouped_list(page, limit, keyword, search_type=search_type, company=company)
# 字段级脱敏 # 字段级脱敏
user_permissions = get_current_user_permissions() user_permissions = get_current_user_permissions()

View File

@ -8,11 +8,12 @@ permission_bp = Blueprint('permission', __name__)
def _get_operator_company(): def _get_operator_company():
"""从 JWT 获取当前操作者公司(None=超管)""" """从 JWT 获取当前操作者公司(None=超管,具体值=该角色所属公司)"""
claims = get_jwt() claims = get_jwt()
role = claims.get('role', '') role = claims.get('role', '')
if role and role.upper() == 'SUPER_ADMIN': if role and role.upper() == 'SUPER_ADMIN':
return None # 超管不限制公司 return None # 超管不限制公司
return claims.get('company_name', '')
def _has_system_permission(role_code): def _has_system_permission(role_code):
@ -24,7 +25,6 @@ def _has_system_permission(role_code):
return 'system_permission' in all_perms return 'system_permission' in all_perms
except Exception: except Exception:
return False return False
return claims.get('company_name', '')
@permission_bp.route('/tree', methods=['GET']) @permission_bp.route('/tree', methods=['GET'])

View File

@ -1,7 +1,7 @@
# inventory-backend/app/api/v1/scrap.py # inventory-backend/app/api/v1/scrap.py
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, audit_log from app.utils.decorators import permission_required, audit_log, get_current_company_filter
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.extensions import db from app.extensions import db
from app.models.transaction import TransScrap, TransRepair from app.models.transaction import TransScrap, TransRepair
@ -322,6 +322,37 @@ class ScrapService:
if end_date: if end_date:
query = query.filter(TransScrap.operation_time <= end_date + ' 23:59:59') query = query.filter(TransScrap.operation_time <= end_date + ' 23:59:59')
# 【行级数据隔离】基于 JWT 多租户公司过滤
# 通过 stock 表或 trans_repair 关联到 MaterialBase
company_limit = get_current_company_filter()
if company_limit is not None:
buy_subq = db.session.query(TransScrap.id).join(
StockBuy, db.and_(TransScrap.stock_id == StockBuy.id,
TransScrap.source_table == 'stock_buy')
).join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
semi_subq = db.session.query(TransScrap.id).join(
StockSemi, db.and_(TransScrap.stock_id == StockSemi.id,
TransScrap.source_table == 'stock_semi')
).join(MaterialBase, StockSemi.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
product_subq = db.session.query(TransScrap.id).join(
StockProduct, db.and_(TransScrap.stock_id == StockProduct.id,
TransScrap.source_table == 'stock_product')
).join(MaterialBase, StockProduct.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
repair_subq = db.session.query(TransScrap.id).join(
TransRepair, db.and_(TransScrap.stock_id == TransRepair.id,
TransScrap.source_table == 'trans_repair')
).join(MaterialBase, TransRepair.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
all_matches = buy_subq.union(semi_subq, product_subq, repair_subq).subquery()
query = query.filter(TransScrap.id.in_(all_matches))
# 按时间倒序 # 按时间倒序
query = query.order_by(TransScrap.operation_time.desc()) query = query.order_by(TransScrap.operation_time.desc())

View File

@ -4,6 +4,7 @@ from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
from app.utils.decorators import get_current_company_filter
from sqlalchemy import func, distinct, or_, case from sqlalchemy import func, distinct, or_, case
from collections import defaultdict from collections import defaultdict
import uuid import uuid
@ -126,6 +127,11 @@ class BomService:
if active_only: if active_only:
query_base = query_base.filter(BomTable.is_enabled == True) query_base = query_base.filter(BomTable.is_enabled == True)
# 【行级数据隔离】基于 JWT 多租户公司过滤
company_limit = get_current_company_filter()
if company_limit is not None:
query_base = query_base.filter(MaterialBase.company_name == company_limit)
if keyword: if keyword:
kw = f'%{keyword}%' kw = f'%{keyword}%'
# 关联子件表以支持子件搜索 # 关联子件表以支持子件搜索

View File

@ -48,6 +48,12 @@ class MaterialBaseService:
) )
) )
# 【行级数据隔离】基于 JWT 多租户公司过滤
from app.utils.decorators import get_current_company_filter
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.filter(MaterialBase.company_name == company_limit)
# [修改1] 增加返回数量限制 # [修改1] 增加返回数量限制
# 原为 limit(20),现改为 1000,确保前端能获取所有(或足够多)的数据 # 原为 limit(20),现改为 1000,确保前端能获取所有(或足够多)的数据
query = query.limit(1000) query = query.limit(1000)

View File

@ -15,7 +15,7 @@ from openpyxl.utils import get_column_letter
class InboundSummaryService: class InboundSummaryService:
@staticmethod @staticmethod
def get_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None, source_type=None): def get_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None, source_type=None, company=None):
""" """
聚合查询: 聚合查询:
1. 联合 StockBuy, StockSemi, StockProduct 三张表 1. 联合 StockBuy, StockSemi, StockProduct 三张表
@ -126,6 +126,10 @@ class InboundSummaryService:
if source_type: if source_type:
query = query.filter(cte.c.source_type == source_type) query = query.filter(cte.c.source_type == source_type)
# 公司过滤(跨域选择器传入)
if company:
query = query.filter(MaterialBase.company_name == company)
# ========================================================= # =========================================================
# 5. 获取总数 # 5. 获取总数
# ========================================================= # =========================================================
@ -139,6 +143,8 @@ class InboundSummaryService:
count_query = count_query.filter(cte.c.inbound_date.between(start_date, end_date)) count_query = count_query.filter(cte.c.inbound_date.between(start_date, end_date))
if source_type: if source_type:
count_query = count_query.filter(cte.c.source_type == source_type) count_query = count_query.filter(cte.c.source_type == source_type)
if company:
count_query = count_query.filter(MaterialBase.company_name == company)
total = count_query.scalar() or 0 total = count_query.scalar() or 0
@ -218,7 +224,7 @@ class InboundSummaryService:
raise e raise e
@staticmethod @staticmethod
def export_excel(keyword=None, start_date=None, end_date=None, source_type=None): def export_excel(keyword=None, start_date=None, end_date=None, source_type=None, company=None):
""" """
导出入库记录 Excel 导出入库记录 Excel
""" """
@ -304,6 +310,9 @@ class InboundSummaryService:
if source_type: if source_type:
query = query.filter(cte.c.source_type == source_type) query = query.filter(cte.c.source_type == source_type)
if company:
query = query.filter(MaterialBase.company_name == company)
# 排序 # 排序
query = query.order_by(desc(cte.c.inbound_date), asc(cte.c.sku)) query = query.order_by(desc(cte.c.inbound_date), asc(cte.c.sku))

View File

@ -1,6 +1,7 @@
# app/services/inbound/product_service.py # app/services/inbound/product_service.py
from app.extensions import db from app.extensions import db
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.utils.decorators import get_current_company_filter
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
from app.models.outbound import TransOutbound from app.models.outbound import TransOutbound
@ -363,6 +364,13 @@ class ProductInboundService:
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.strip())
# ============================================================
# 【行级数据隔离】基于 JWT 多租户公司过滤
# ============================================================
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.filter(MaterialBase.company_name == company_limit)
# ============================================================ # ============================================================
# 【全局特权】基于 JWT 与 global:cross_company_op 的跨组织隔离 # 【全局特权】基于 JWT 与 global:cross_company_op 的跨组织隔离
# ============================================================ # ============================================================

View File

@ -1,6 +1,7 @@
# app/services/inbound/semi_service.py # app/services/inbound/semi_service.py
from app.extensions import db from app.extensions import db
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.utils.decorators import get_current_company_filter
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
from app.models.outbound import TransOutbound from app.models.outbound import TransOutbound
@ -453,6 +454,13 @@ class SemiInboundService:
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.strip())
# ============================================================
# 【行级数据隔离】基于 JWT 多租户公司过滤
# ============================================================
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.filter(MaterialBase.company_name == company_limit)
# ============================================================ # ============================================================
# 【全局特权】基于 JWT 与 global:cross_company_op 的跨组织隔离 # 【全局特权】基于 JWT 与 global:cross_company_op 的跨组织隔离
# ============================================================ # ============================================================

View File

@ -158,9 +158,13 @@ class ServiceService:
try: try:
query = StockService.query.filter_by(is_deleted=False) query = StockService.query.filter_by(is_deleted=False)
# 始终 join MaterialBase(base_id 为 NOT NULL,inner join 不会改变结果行数)
# 用于公司过滤和关键词搜索
query = query.join(StockService.base)
# 关键词联表搜索 # 关键词联表搜索
if keyword: if keyword:
query = query.join(StockService.base).filter( query = query.filter(
db.or_( db.or_(
StockService.sku.ilike(f'%{keyword}%'), StockService.sku.ilike(f'%{keyword}%'),
MaterialBase.name.ilike(f'%{keyword}%'), MaterialBase.name.ilike(f'%{keyword}%'),
@ -168,6 +172,13 @@ class ServiceService:
) )
) )
# 【行级数据隔离】基于 JWT 多租户公司过滤
from app.utils.decorators import get_current_company_filter
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.filter(MaterialBase.company_name == company_limit)
# 日期过滤 # 日期过滤
if start_date: if start_date:
try: try:

View File

@ -274,11 +274,12 @@ class OutboundService:
raise e raise e
@staticmethod @staticmethod
def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None): def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None, company=None):
""" """
查询出库记录(按出库单号分组),包含详细物品信息 查询出库记录(按出库单号分组),包含详细物品信息
支持跨表搜索:单号、领用人、SKU、物料名称、规格型号 支持跨表搜索:单号、领用人、SKU、物料名称、规格型号
search_type: all, no, name, sku, material_name, spec_model search_type: all, no, name, sku, material_name, spec_model
company: 可选的公司过滤参数
""" """
# 日期补全:解决零点截断问题 # 日期补全:解决零点截断问题
if end_date and len(str(end_date).strip()) == 10: if end_date and len(str(end_date).strip()) == 10:
@ -436,6 +437,44 @@ class OutboundService:
else: else:
keyword_conditions = None keyword_conditions = None
# 【行级数据隔离】基于 JWT 多租户公司过滤
# 通过三个库存表路径,找到匹配公司的出库单号(排除 trans_repair,因其无 MaterialBase 关联)
from app.utils.decorators import get_current_company_filter
company_limit = get_current_company_filter()
if company_limit is not None:
buy_comp = db.session.query(TransOutbound.outbound_no).join(
StockBuy, and_(
TransOutbound.stock_id == StockBuy.id,
TransOutbound.source_table == 'stock_buy'
)
).join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
).subquery()
semi_comp = db.session.query(TransOutbound.outbound_no).join(
StockSemi, and_(
TransOutbound.stock_id == StockSemi.id,
TransOutbound.source_table == 'stock_semi'
)
).join(MaterialBase, StockSemi.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
).subquery()
prod_comp = db.session.query(TransOutbound.outbound_no).join(
StockProduct, and_(
TransOutbound.stock_id == StockProduct.id,
TransOutbound.source_table == 'stock_product'
)
).join(MaterialBase, StockProduct.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
).subquery()
comp_all = db.session.query(buy_comp.c.outbound_no).union(
db.session.query(semi_comp.c.outbound_no),
db.session.query(prod_comp.c.outbound_no)
).subquery()
stmt = db.session.query( stmt = db.session.query(
TransOutbound.outbound_no, TransOutbound.outbound_no,
func.max(TransOutbound.outbound_time).label('max_time') func.max(TransOutbound.outbound_time).label('max_time')
@ -447,6 +486,10 @@ class OutboundService:
if start_date and end_date: if start_date and end_date:
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date)) stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
# 【行级数据隔离】应用公司过滤到主查询
if company_limit is not None:
stmt = stmt.filter(TransOutbound.outbound_no.in_(comp_all))
stmt = stmt.order_by(desc('max_time')) stmt = stmt.order_by(desc('max_time'))
# 使用 distinct 确保跨表查询不重复 # 使用 distinct 确保跨表查询不重复

View File

@ -6,6 +6,7 @@ from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.utils.decorators import get_current_company_filter
from sqlalchemy import desc, func, nullslast, asc, or_, and_, case from sqlalchemy import desc, func, nullslast, asc, or_, and_, case
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@ -523,6 +524,35 @@ class TransService:
.subquery() .subquery()
) )
# ====================================================================
# 【行级数据隔离】基于 JWT 多租户公司过滤
# 通过 stock 表关联到 MaterialBase,确保只返回本公司借还记录
# ====================================================================
company_borrow_nos_subq = None
company_limit = get_current_company_filter()
if company_limit is not None:
buy_nos = db.session.query(TransBorrow.borrow_no).join(
StockBuy, and_(TransBorrow.stock_id == StockBuy.id,
TransBorrow.source_table == 'stock_buy')
).join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
semi_nos = db.session.query(TransBorrow.borrow_no).join(
StockSemi, and_(TransBorrow.stock_id == StockSemi.id,
TransBorrow.source_table == 'stock_semi')
).join(MaterialBase, StockSemi.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
product_nos = db.session.query(TransBorrow.borrow_no).join(
StockProduct, and_(TransBorrow.stock_id == StockProduct.id,
TransBorrow.source_table == 'stock_product')
).join(MaterialBase, StockProduct.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
company_borrow_nos_subq = buy_nos.union(
semi_nos, product_nos
).subquery()
# ==================================================================== # ====================================================================
# 步骤 2:纯净列查询分页(SELECT 只有 order_subq.c.borrow_no 一列) # 步骤 2:纯净列查询分页(SELECT 只有 order_subq.c.borrow_no 一列)
# ==================================================================== # ====================================================================
@ -534,6 +564,12 @@ class TransService:
order_subq.c.borrow_no.in_(keyword_borrow_nos_subq) order_subq.c.borrow_no.in_(keyword_borrow_nos_subq)
) )
# 公司隔离过滤
if company_borrow_nos_subq is not None:
borrow_no_q = borrow_no_q.filter(
order_subq.c.borrow_no.in_(company_borrow_nos_subq)
)
# 状态过滤(按"单号聚合"判定) # 状态过滤(按"单号聚合"判定)
if status == 'borrowed': if status == 'borrowed':
# 单号下至少一条未还 # 单号下至少一条未还

View File

@ -227,7 +227,10 @@ def get_current_company_filter():
return None return None
# 普通用户 → 强制隔离到本公司 # 普通用户 → 强制隔离到本公司
return user_company if user_company else None # 如果 JWT 中没有 company_name,返回哨兵值确保不会匹配任何数据
if user_company:
return user_company
return '__NO_COMPANY__'
def audit_log(module: str = None, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None): def audit_log(module: str = None, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None):

View File

@ -14,7 +14,7 @@ BEGIN;
-- 1. 向 sys_element 插入跨域权限码(挂 system 菜单下) -- 1. 向 sys_element 插入跨域权限码(挂 system 菜单下)
INSERT INTO sys_element (menu_code, name, code, element_type) INSERT INTO sys_element (menu_code, name, code, element_type)
SELECT 'system_mgmt', '全局跨域访问', 'crossDomain', 'button' SELECT 'system_mgmt', '全局跨域访问', 'crossDomain', 'element'
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM sys_element WHERE code = 'crossDomain' SELECT 1 FROM sys_element WHERE code = 'crossDomain'
); );
@ -29,7 +29,7 @@ WHERE NOT EXISTS (
SELECT 1 FROM sys_menu WHERE code = 'system_cross_domain' SELECT 1 FROM sys_menu WHERE code = 'system_cross_domain'
); );
-- 3. 默认分配给 SUPER_ADMIN 角色 -- 3. 默认分配给 SUPER_ADMIN 和 SUPERVISOR 角色
INSERT INTO sys_role_permission (role_code, target_code, type) INSERT INTO sys_role_permission (role_code, target_code, type)
SELECT 'SUPER_ADMIN', 'crossDomain', 'element' SELECT 'SUPER_ADMIN', 'crossDomain', 'element'
WHERE NOT EXISTS ( WHERE NOT EXISTS (
@ -37,6 +37,13 @@ WHERE NOT EXISTS (
WHERE role_code = 'SUPER_ADMIN' AND target_code = 'crossDomain' WHERE role_code = 'SUPER_ADMIN' AND target_code = 'crossDomain'
); );
INSERT INTO sys_role_permission (role_code, target_code, type)
SELECT 'SUPERVISOR', 'crossDomain', 'element'
WHERE NOT EXISTS (
SELECT 1 FROM sys_role_permission
WHERE role_code = 'SUPERVISOR' AND target_code = 'crossDomain'
);
-- 4. 验证 -- 4. 验证
SELECT code, name, menu_code, element_type SELECT code, name, menu_code, element_type
FROM sys_element WHERE code = 'crossDomain'; FROM sys_element WHERE code = 'crossDomain';