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

View File

@ -179,10 +179,10 @@ def get_outbound_list():
limit = int(request.args.get('limit', 10))
keyword = request.args.get('keyword', '')
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()

View File

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

View File

@ -1,7 +1,7 @@
# inventory-backend/app/api/v1/scrap.py
from flask import Blueprint, request, jsonify
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.extensions import db
from app.models.transaction import TransScrap, TransRepair
@ -322,6 +322,37 @@ class ScrapService:
if end_date:
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())

View File

@ -4,6 +4,7 @@ from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from app.utils.decorators import get_current_company_filter
from sqlalchemy import func, distinct, or_, case
from collections import defaultdict
import uuid
@ -126,6 +127,11 @@ class BomService:
if active_only:
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:
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] 增加返回数量限制
# 原为 limit(20),现改为 1000,确保前端能获取所有(或足够多)的数据
query = query.limit(1000)

View File

@ -15,7 +15,7 @@ from openpyxl.utils import get_column_letter
class InboundSummaryService:
@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 三张表
@ -126,6 +126,10 @@ class InboundSummaryService:
if source_type:
query = query.filter(cte.c.source_type == source_type)
# 公司过滤(跨域选择器传入)
if company:
query = query.filter(MaterialBase.company_name == company)
# =========================================================
# 5. 获取总数
# =========================================================
@ -139,6 +143,8 @@ class InboundSummaryService:
count_query = count_query.filter(cte.c.inbound_date.between(start_date, end_date))
if 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
@ -218,7 +224,7 @@ class InboundSummaryService:
raise e
@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
"""
@ -304,6 +310,9 @@ class InboundSummaryService:
if 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))

View File

@ -1,6 +1,7 @@
# app/services/inbound/product_service.py
from app.extensions import db
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.semi import StockSemi
from app.models.outbound import TransOutbound
@ -363,6 +364,13 @@ class ProductInboundService:
if material_type and 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 的跨组织隔离
# ============================================================

View File

@ -1,6 +1,7 @@
# app/services/inbound/semi_service.py
from app.extensions import db
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.product import StockProduct
from app.models.outbound import TransOutbound
@ -453,6 +454,13 @@ class SemiInboundService:
if material_type and 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 的跨组织隔离
# ============================================================

View File

@ -158,9 +158,13 @@ class ServiceService:
try:
query = StockService.query.filter_by(is_deleted=False)
# 始终 join MaterialBase(base_id 为 NOT NULL,inner join 不会改变结果行数)
# 用于公司过滤和关键词搜索
query = query.join(StockService.base)
# 关键词联表搜索
if keyword:
query = query.join(StockService.base).filter(
query = query.filter(
db.or_(
StockService.sku.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:
try:

View File

@ -274,11 +274,12 @@ class OutboundService:
raise e
@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、物料名称、规格型号
search_type: all, no, name, sku, material_name, spec_model
company: 可选的公司过滤参数
"""
# 日期补全:解决零点截断问题
if end_date and len(str(end_date).strip()) == 10:
@ -436,6 +437,44 @@ class OutboundService:
else:
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(
TransOutbound.outbound_no,
func.max(TransOutbound.outbound_time).label('max_time')
@ -447,6 +486,10 @@ class OutboundService:
if start_date and 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'))
# 使用 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.product import StockProduct
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.orm import joinedload
@ -523,6 +524,35 @@ class TransService:
.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 一列)
# ====================================================================
@ -534,6 +564,12 @@ class TransService:
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':
# 单号下至少一条未还

View File

@ -227,7 +227,10 @@ def get_current_company_filter():
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):

View File

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