From e1417d740a0a816990ad4384f852e200be75f147 Mon Sep 17 00:00:00 2001 From: yueli Date: Wed, 15 Jul 2026 11:11:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E6=A8=A1=E5=9D=97=E5=85=AC?= =?UTF-8?q?=E5=8F=B8=E9=9A=94=E7=A6=BB=20+=20crossDomain=E6=9D=83=E9=99=90?= =?UTF-8?q?=E7=A0=81=E5=8A=A8=E6=80=81=E8=B7=A8=E5=9F=9F=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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下 --- .../app/api/v1/inbound/inbound_summary.py | 8 +++- inventory-backend/app/api/v1/outbound.py | 4 +- inventory-backend/app/api/v1/permission.py | 4 +- inventory-backend/app/api/v1/scrap.py | 33 +++++++++++++- inventory-backend/app/services/bom_service.py | 6 +++ .../app/services/inbound/base_service.py | 6 +++ .../inbound/inbound_summary_service.py | 13 +++++- .../app/services/inbound/product_service.py | 8 ++++ .../app/services/inbound/semi_service.py | 8 ++++ .../app/services/inbound/service_service.py | 13 +++++- .../app/services/outbound_service.py | 45 ++++++++++++++++++- .../app/services/trans_service.py | 36 +++++++++++++++ inventory-backend/app/utils/decorators.py | 5 ++- ...migrations_add_cross_domain_permission.sql | 11 ++++- 14 files changed, 186 insertions(+), 14 deletions(-) diff --git a/inventory-backend/app/api/v1/inbound/inbound_summary.py b/inventory-backend/app/api/v1/inbound/inbound_summary.py index 07fe2a3..82f0d11 100644 --- a/inventory-backend/app/api/v1/inbound/inbound_summary.py +++ b/inventory-backend/app/api/v1/inbound/inbound_summary.py @@ -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 diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py index 49157c7..57b8d51 100644 --- a/inventory-backend/app/api/v1/outbound.py +++ b/inventory-backend/app/api/v1/outbound.py @@ -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() diff --git a/inventory-backend/app/api/v1/permission.py b/inventory-backend/app/api/v1/permission.py index a06e1c8..5c8e410 100644 --- a/inventory-backend/app/api/v1/permission.py +++ b/inventory-backend/app/api/v1/permission.py @@ -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']) diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py index b9223e7..983e519 100644 --- a/inventory-backend/app/api/v1/scrap.py +++ b/inventory-backend/app/api/v1/scrap.py @@ -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()) diff --git a/inventory-backend/app/services/bom_service.py b/inventory-backend/app/services/bom_service.py index a488e73..9addc97 100644 --- a/inventory-backend/app/services/bom_service.py +++ b/inventory-backend/app/services/bom_service.py @@ -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}%' # 关联子件表以支持子件搜索 diff --git a/inventory-backend/app/services/inbound/base_service.py b/inventory-backend/app/services/inbound/base_service.py index ad15e0b..4c02766 100644 --- a/inventory-backend/app/services/inbound/base_service.py +++ b/inventory-backend/app/services/inbound/base_service.py @@ -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) diff --git a/inventory-backend/app/services/inbound/inbound_summary_service.py b/inventory-backend/app/services/inbound/inbound_summary_service.py index 46f0b04..5883f15 100644 --- a/inventory-backend/app/services/inbound/inbound_summary_service.py +++ b/inventory-backend/app/services/inbound/inbound_summary_service.py @@ -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)) diff --git a/inventory-backend/app/services/inbound/product_service.py b/inventory-backend/app/services/inbound/product_service.py index 7c55c45..e723d47 100644 --- a/inventory-backend/app/services/inbound/product_service.py +++ b/inventory-backend/app/services/inbound/product_service.py @@ -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 的跨组织隔离 # ============================================================ diff --git a/inventory-backend/app/services/inbound/semi_service.py b/inventory-backend/app/services/inbound/semi_service.py index 0c7ccd1..48b0510 100644 --- a/inventory-backend/app/services/inbound/semi_service.py +++ b/inventory-backend/app/services/inbound/semi_service.py @@ -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 的跨组织隔离 # ============================================================ diff --git a/inventory-backend/app/services/inbound/service_service.py b/inventory-backend/app/services/inbound/service_service.py index 34dd82c..980e101 100644 --- a/inventory-backend/app/services/inbound/service_service.py +++ b/inventory-backend/app/services/inbound/service_service.py @@ -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: diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index e8885e3..cd0b83a 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -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 确保跨表查询不重复 diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index 151581d..8d1b78b 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -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': # 单号下至少一条未还 diff --git a/inventory-backend/app/utils/decorators.py b/inventory-backend/app/utils/decorators.py index 9dffee2..fb9fa55 100644 --- a/inventory-backend/app/utils/decorators.py +++ b/inventory-backend/app/utils/decorators.py @@ -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): diff --git a/inventory-backend/migrations_add_cross_domain_permission.sql b/inventory-backend/migrations_add_cross_domain_permission.sql index 7d29943..96bc060 100644 --- a/inventory-backend/migrations_add_cross_domain_permission.sql +++ b/inventory-backend/migrations_add_cross_domain_permission.sql @@ -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';