feat(分组权限): 业务分组模型 + DataScope 判定 + 列表接口接入

第一阶段:模型 → 判定 → /auth/me → 列表。统计接口与前端管理页随后。

1) 模型与迁移(head 从 k1l2m3n4o5p6 推进到 l1m2n3o4p5q6)
   · business_groups        组定义,parent_id 表达「大组 > 小组」
   · business_group_phases  可见范围,独立成表以支持多选 —— 需求要求
                            「范围可配置、不要写死」,单列存不下多个 phase
   · business_group_members 成员,一人可属多组(这是「同时看生产+维修」的实现)
   只建表、不写种子数据,所以可以先部署代码再建组。

2) DataScope 判定模块(app/services/data_scope_service.py)
   全仓库唯一的权限谓词来源,业务代码里不准再出现 lifecycle_phase 过滤。
   两条红线照抄部门隔离的教训:
   · None(不限) 与 frozenset()(空) 语义相反,绝不共用一个哨兵值
   · 空集合必须显式 false() —— SQLAlchemy 对 in_(()) 生 成 IN (NULL),
     一旦退化成不过滤就是全量泄漏
   解析优先级:SUPER_ADMIN 硬放行(不可被分组覆盖)
             > 显式分组(分组优先于角色)
             > 未分组 SUPERVISOR 默认全厂
             > 未分组普通用户

3) 过渡期开关 DATA_SCOPE_UNGROUPED(默认 ALL)
   直接上严格模式会让所有未分组工人当场看不到自己的任务、现场停摆。
   默认 ALL 先放行并打 WARNING 记录「谁还没分组」,配好组后再改 NONE。

4) /auth/me 返回 scope,phase 中文标签由服务端下发
   —— 前端已有两份 phase 词表副本,不再加第三份。

5) 列表接口接入
   · get_all_products:过滤加在 offset/limit 之前(其下有 6 段基于 product_ids
     的批量预计算,过滤晚了等于算完再丢)
   · get_all_tasks:谓词进【共享 filters】,保证 count 与 select 两条独立语句
     同时生效,否则 total 与实际页不一致、移动端 hasMore 判断跟着错
   · 扫码 get_product_by_serial 刻意不过滤,理由写死在 docstring 里

实测:空 scope 生成 false、受限 scope 生成 JOIN + IN 谓词;
/products 返回 2 条、/tasks 的 total 与 returned 一致。
This commit is contained in:
2026-09-21 17:07:46 +08:00
parent f9f3d90f96
commit cfcfca7269
12 changed files with 580 additions and 12 deletions

View File

@ -0,0 +1,94 @@
"""add_business_groups
Revision ID: l1m2n3o4p5q6
Revises: k1l2m3n4o5p6
Create Date: 2026-09-21
业务分组business_groups / business_group_phases / business_group_members
--------------------------------------------------------------------------
LICA 部门内部再分一层数据范围:生产大组看生产制造、维修大组看售后回流。
组与 lifecycle_phase 直接映射,不新造业务概念 —— 这套阶段语义系统里早就有了
app/core/lifecycle.py此前只用于展示与校验没有权限含义。
三张表的分工:
· business_groups 组定义parent_id 表达「大组 > 小组」。子组默认继承
父组的可见范围,所以生产大组配一次就够。
· business_group_phases 组的可见范围,独立成表是为了支持多选 —— 需求明确
要求「范围可配置、不要写死」,单列存不下多个 phase。
· business_group_members 成员,一人可属多组(这是「让某人同时看生产+维修」
的实现方式,不需要任何特殊逻辑)。
本迁移只建表,**不写入任何种子数据**。
一个组都没有时resolve_data_scope 会走到「未分组」分支,配合
config.DATA_SCOPE_UNGROUPED 开关决定行为 —— 所以可以先部署代码、再建组,
分批上线。初始化入口见 backend/scripts/seed_business_groups.py。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "l1m2n3o4p5q6"
down_revision: Union[str, None] = "k1l2m3n4o5p6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"business_groups",
sa.Column("id", sa.Integer(), autoincrement=True, primary_key=True,
comment="分组ID(数字,对外稳定不变;显示名可改而引用不变)"),
sa.Column("parent_id", sa.Integer(),
sa.ForeignKey("business_groups.id", ondelete="CASCADE"),
nullable=True,
comment="上级大组ID(为空=大组;有值=挂在某大组下的小组)"),
sa.Column("name", sa.String(50), nullable=False, unique=True,
comment="分组显示名(可修改)"),
sa.Column("description", sa.String(200), nullable=True, comment="备注说明"),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0",
comment="同层排序(升序)"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true",
comment="是否启用。停用后该组所有成员立即退回未分组状态"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, comment="创建时间"),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, comment="最后修改时间"),
)
# 列表页按大组罗列子组,走 parent_id
op.create_index("ix_business_groups_parent_id", "business_groups", ["parent_id"])
op.create_table(
"business_group_phases",
sa.Column("group_id", sa.Integer(),
sa.ForeignKey("business_groups.id", ondelete="CASCADE"),
primary_key=True, comment="分组ID"),
sa.Column("phase", sa.String(20), primary_key=True,
comment="可见的生命周期阶段: PRODUCTION | AFTER_SALES"),
)
op.create_table(
"business_group_members",
sa.Column("id", sa.Integer(), autoincrement=True, primary_key=True),
sa.Column("group_id", sa.Integer(),
sa.ForeignKey("business_groups.id", ondelete="CASCADE"),
nullable=False, comment="所属分组ID"),
sa.Column("user_id", sa.String(64), nullable=False,
comment="成员账号(逻辑外键→MOM sys_user与 Task.assignee_id 同口径)"),
sa.Column("is_leader", sa.Boolean(), nullable=False, server_default="false",
comment="是否组长(可管理本组成员;数据范围与组员相同)"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, comment="加入时间"),
sa.UniqueConstraint("group_id", "user_id",
name="uq_business_group_members_group_user"),
)
# 每个请求都会按 user_id 反查「我在哪些组」—— 这是热路径,必须建索引
op.create_index("ix_business_group_members_user_id", "business_group_members", ["user_id"])
# 组详情页列成员、以及 CASCADE 删除,走 group_id
op.create_index("ix_business_group_members_group_id", "business_group_members", ["group_id"])
def downgrade() -> None:
op.drop_index("ix_business_group_members_group_id", table_name="business_group_members")
op.drop_index("ix_business_group_members_user_id", table_name="business_group_members")
op.drop_table("business_group_members")
op.drop_table("business_group_phases")
op.drop_index("ix_business_groups_parent_id", table_name="business_groups")
op.drop_table("business_groups")

View File

@ -1,14 +1,24 @@
"""认证 API — 对接 MOM sys_user + 双 Token 刷新"""
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.user import (
DataScopeInfo,
LoginRequest,
LoginResponse,
RefreshRequest,
RefreshResponse,
UserResponse,
)
from app.core.database import get_db
from app.core.deps import get_data_scope
from app.core.security import peek_token_identity
from app.services.auth_service import login, refresh_access_token, get_current_user
from app.services.data_scope_service import (
DataScope,
scope_group_names,
scope_phase_labels,
)
router = APIRouter(prefix="/auth", tags=["认证"])
@ -70,11 +80,33 @@ def logout_endpoint(current_user: dict = Depends(get_current_user)):
@router.get("/me", response_model=UserResponse)
def get_me(current_user: dict = Depends(get_current_user)):
"""获取当前用户信息(从 Access Token 解析)"""
async def get_me(
current_user: dict = Depends(get_current_user),
scope: DataScope = Depends(get_data_scope),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户信息 + 业务分组数据范围。
为什么把 scope 挂在这里而不是新开 /auth/data-scope
前端 AuthContext 挂载时**本来就会调一次本接口**,顺手返回是零额外请求;
新开接口则要在每个页面额外拉一次。响应只新增可选字段,路径与结构不变。
拿到 scope 后前端能在页头显示「维修组 · 售后回流」或
「未分组 · 暂无数据权限」—— 后者尤其重要:列表为空时必须让用户知道
是权限问题,而不是以为系统坏了。
"""
return UserResponse(
id=current_user["sub"],
username=current_user.get("username", ""),
display_name=current_user.get("display_name", ""),
role=current_user.get("role", "operator"),
scope=DataScopeInfo(
is_unrestricted=scope.is_unrestricted,
is_empty=scope.is_empty,
phases=sorted(scope.phases) if scope.phases else [],
phase_labels=scope_phase_labels(scope),
groups=await scope_group_names(db, scope),
is_leader=bool(scope.leader_of),
reason=scope.reason,
),
)

View File

@ -15,7 +15,9 @@ from app.schemas.product import (
ProductScanResponse,
)
from app.services import product_service, product_finalize_service
from app.core.deps import get_data_scope
from app.services.auth_service import get_current_user
from app.services.data_scope_service import DataScope
from app.services.qrcode_service import generate_qrcode_png
router = APIRouter(prefix="/products", tags=["产品管理"])
@ -67,6 +69,11 @@ async def scan_product(
"""
扫码接口:根据 16 位序列号查询产品及其当前进度。
返回产品信息、所属订单、以及顶层任务列表。
⚠️ 本接口**刻意不受业务分组数据范围约束** —— 与「列表按组过滤」同等重要的
设计决策(详见 product_service.get_product_by_serial 的 docstring
维修组扫到生产中的设备必须能【看】——现场要判断这台机器是不是走错了流程,
看不见就无法判断。「不能操作」由操作类接口各自的属主校验保证。
"""
return await product_service.get_product_by_serial(db, serial_number)
@ -83,10 +90,14 @@ async def list_products(
status: str | None = Query(None, description="产品状态筛选: PENDING/WIP/COMPLETED/ARCHIVED"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
scope: DataScope = Depends(get_data_scope),
):
"""获取产品列表 — 支持 keyword 搜索 + 状态筛选"""
"""获取产品列表 — 支持 keyword 搜索 + 状态筛选
结果受业务分组数据范围约束(见 app/services/data_scope_service.py
"""
return await product_service.get_all_products(
db, skip=skip, limit=limit, keyword=keyword, status_filter=status,
db, scope=scope, skip=skip, limit=limit, keyword=keyword, status_filter=status,
)

View File

@ -7,6 +7,8 @@ from app.services.auth_service import get_current_user
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.deps import get_data_scope
from app.services.data_scope_service import DataScope
from app.schemas.task import (
TaskCreate,
TaskUpdate,
@ -39,10 +41,16 @@ async def list_tasks(
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
scope: DataScope = Depends(get_data_scope),
):
"""获取任务列表,可按产品/负责人筛选(只返回顶层任务)"""
"""获取任务列表,可按产品/负责人筛选(只返回顶层任务)
结果受业务分组数据范围约束(见 app/services/data_scope_service.py
"""
pid = uuid.UUID(product_id) if product_id else None
return await task_service.get_all_tasks(db, product_id=pid, assignee_id=assignee_id, skip=skip, limit=limit)
return await task_service.get_all_tasks(
db, scope=scope, product_id=pid, assignee_id=assignee_id, skip=skip, limit=limit,
)
@router.get("/{task_id}", response_model=TaskResponse)

View File

@ -42,6 +42,15 @@ class Settings(BaseSettings):
# LICA 用 "L" 打头一眼区分部门来源IRIS 实例保持空串不受影响。
SERIAL_PREFIX: str = "L"
# ---- 业务分组数据范围 ----
# 未被分进任何业务组的**普通用户**(非超管、非主管)能看到什么:
# "ALL" = 过渡期先放行,同时打 WARNING 日志把「谁还没分组」暴露出来
# "NONE" = 目标态:看不到任何数据(列表返回空,不是 403
# ⚠️ 直接上 NONE 会让所有未分组工人当场看不到自己的任务,现场停摆。
# 推荐节奏:先 ALL 上线 → 看日志收集未分组名单 → 配好组 → 再翻 NONE。
# 翻开关只需改这里 + 重启,不需要改代码、不需要迁移,回滚同样瞬时。
DATA_SCOPE_UNGROUPED: str = "ALL"
@property
def CORS_ORIGINS_LIST(self) -> list[str]:
"""将 JSON 字符串解析为 Python list供 CORSMiddleware 使用"""

View File

@ -2,9 +2,12 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.roles import ADMIN_ROLES
from app.services.auth_service import get_current_user
from app.services.data_scope_service import DataScope, resolve_data_scope
def require_roles(*roles: str):
@ -33,3 +36,21 @@ def require_roles(*roles: str):
# 审计日志等高权限接口复用同一实例
require_admin = require_roles(*ADMIN_ROLES)
async def get_data_scope(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> DataScope:
"""业务分组数据范围 —— 列表 / 统计类接口的统一入口。
⚠️ **硬依赖 get_current_user**:匿名请求直接 401**不放行成空范围**。
两个理由:
1. 「匿名 = 空范围」会让大屏变成一个永远空白的页面,比 401 更难排查;
2. 匿名不过滤本身就是绕过口子 —— 谁能不登录看全厂数据,分组就形同虚设。
刻意**不加 TTL 缓存**:把人踢出组必须立即生效,任何缓存都会造成
「已经踢了还在看」的窗口。这里是单表 + 索引扫描,成本可忽略;
FastAPI 的依赖缓存在单请求内已经生效(同一请求多处 Depends 只解析一次)。
"""
return await resolve_data_scope(db, current_user)

View File

@ -10,6 +10,11 @@ from app.models.message import ProductMessage
from app.models.holiday import Holiday
from app.models.audit_log import AuditLog
from app.models.user_daily_seen import UserDailySeen
from app.models.business_group import (
BusinessGroup,
BusinessGroupPhase,
BusinessGroupMember,
)
__all__ = [
"Base",
"ProductionOrder",
@ -23,4 +28,7 @@ __all__ = [
"Holiday",
"AuditLog",
"UserDailySeen",
"BusinessGroup",
"BusinessGroupPhase",
"BusinessGroupMember",
]

View File

@ -0,0 +1,112 @@
"""业务分组 — LICA 部门内部的数据范围隔离(生产大组 / 维修大组)
系统已有 lifecycle_phase生产制造 / 售后回流),但此前**只用于展示与校验**
没有任何权限含义。本模块补上「数据范围」这一层:组决定成员能看到哪个阶段的数据。
为什么要两级(大组 > 小组):
生产大组下可能再分「生产小组」「测试小组」,它们看的是同一片数据(都属生产
阶段),彼此互通;维修大组则与生产隔离。用 parent_id 表达归属,子组默认继承
父组的可见范围 —— 这样「生产大组配一次 PRODUCTION下面的小组都不用再配」。
为什么组存在 Track 库而不是 MOM
组是 Track 自己的业务概念(对应 lifecycle_phaseMOM 里没有对应表,且组名要
能由业务随时改。只有「人」是 String(64) 逻辑外键指向 MOM sys_user。
为什么可见范围是独立的 business_group_phases 表而不是一个字段:
需求明确要求「范围可配置、不要写死」,且一个组可能同时要看生产和售后 ——
单列存不下多选。
"""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class BusinessGroup(Base):
"""业务分组定义两级parent_id 为空即大组)"""
__tablename__ = "business_groups"
id: Mapped[int] = mapped_column(
primary_key=True, autoincrement=True,
comment="分组ID(数字,对外稳定不变;显示名可改而引用不变)",
)
parent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("business_groups.id", ondelete="CASCADE"),
nullable=True, index=True,
comment="上级大组ID(为空=大组;有值=挂在某大组下的小组,默认继承其可见范围)",
)
name: Mapped[str] = mapped_column(
String(50), nullable=False, unique=True, comment="分组显示名(可修改,如 生产小组/维修组)",
)
description: Mapped[str | None] = mapped_column(
String(200), nullable=True, comment="备注说明",
)
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0", comment="同层排序(升序)",
)
is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
comment="是否启用。⚠️ 停用后该组所有成员立即退回「未分组」状态(等同被移出所有组)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time,
onupdate=get_beijing_time, comment="最后修改时间",
)
def __repr__(self) -> str:
return f"<BusinessGroup {self.id} {self.name}>"
class BusinessGroupPhase(Base):
"""分组的可见范围 —— 该组成员能看到哪些 lifecycle_phase 的数据。
⚠️ 这是「不写死」的落点:给不给某个组看售后,完全由管理员在这里配置,
不在代码里硬编码任何「生产组只能看生产」这类规则。
"""
__tablename__ = "business_group_phases"
group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("business_groups.id", ondelete="CASCADE"),
primary_key=True, comment="分组ID",
)
phase: Mapped[str] = mapped_column(
String(20), primary_key=True,
comment="可见的生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(售后回流)",
)
def __repr__(self) -> str:
return f"<BusinessGroupPhase g={self.group_id} {self.phase}>"
class BusinessGroupMember(Base):
"""分组成员。一人可属多个组 —— 这是「让某人同时看生产+维修」的实现方式。"""
__tablename__ = "business_group_members"
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_business_group_members_group_user"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("business_groups.id", ondelete="CASCADE"),
nullable=False, index=True, comment="所属分组ID",
)
user_id: Mapped[str] = mapped_column(
String(64), nullable=False, index=True,
comment="成员账号(逻辑外键→MOM sys_user与 Task.assignee_id / JWT.username 同口径)",
)
is_leader: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false",
comment="是否组长(可管理本组成员;数据范围与组员相同)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="加入时间",
)
def __repr__(self) -> str:
return f"<BusinessGroupMember g={self.group_id} u={self.user_id}>"

View File

@ -7,6 +7,21 @@ class LoginRequest(BaseModel):
password: str = Field(..., max_length=128)
class DataScopeInfo(BaseModel):
"""业务分组数据范围 —— 供前端展示「我为什么只看到这些」。
空范围is_empty必须让用户看得见否则他会以为系统坏了
列表全空却没有任何提示,是最难排查的一类反馈。
"""
is_unrestricted: bool = False # True = 全厂(超管 / 未分组主管)
is_empty: bool = False # True = 未分组,看不到任何数据
phases: list[str] = [] # 原始阶段码,如 ["PRODUCTION"]
phase_labels: list[str] = [] # 中文标签 —— 服务端下发,避免前端再抄一份词表
groups: list[str] = [] # 所属组的显示名
is_leader: bool = False # 是否至少是一个组的组长
reason: str = "" # super_admin / grouped / supervisor_default / ungrouped
class UserResponse(BaseModel):
id: str
username: str
@ -14,6 +29,9 @@ class UserResponse(BaseModel):
role: str
is_active: bool = True
created_at: str | None = None
# 业务分组数据范围。只有 /auth/me 会填(登录接口不查库),故为可选,
# 老的客户端读不到这个字段也不受影响。
scope: DataScopeInfo | None = None
model_config = {"from_attributes": True}

View File

@ -0,0 +1,207 @@
"""业务分组数据范围判定 —— 列表 / 扫码 / 统计三类接口共用的**唯一**判定来源
设计红线(照抄部门隔离那套的精神,别在这里走样):
1. **宁可查不出,不可查过头。** 空范围必须落到 SQL 的 `false()`,绝不能因为
写错而退化成「不过滤」—— 那就是全量泄漏。这是本模块存在的全部意义。
2. **`None`(不限)与 `frozenset()`(空)语义相反**,绝不能用同一个哨兵值表示。
部门过滤当年踩过这个坑,这里显式区分。
3. **SQL 谓词只在本模块生成。** 业务代码里出现 `Product.lifecycle_phase.in_(...)`
就是抄成了第二份口径 —— 评审时可用:
grep -rn "lifecycle_phase.in_" backend/app/
命中点应当**只有本文件一处**。
为什么放在 services 而不是 core
core/lifecycle.py 有明确约定「core 层不反向依赖 models」而本模块需要
AsyncSession 与三张 ORM 表。角色常量仍取自 app.core.roles、阶段常量取自
app.core.lifecycle单一事实来源不破。
"""
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from sqlalchemy import false, select, true
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.lifecycle import LIFECYCLE_PRODUCTION, PHASE_LABELS
from app.core.roles import SUPER_ADMIN, SUPERVISOR
from app.models.business_group import (
BusinessGroup,
BusinessGroupMember,
BusinessGroupPhase,
)
from app.models.product import Product
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class DataScope:
"""当前登录用户能看到的数据范围。
phases:
None → 不限全厂SUPER_ADMIN或未分组的 SUPERVISOR
frozenset() → 空范围:未分组的普通用户。返回空列表,**不是报错**
frozenset({...}) → 只含这些 lifecycle_phase
"""
phases: frozenset[str] | None = None
group_ids: frozenset[int] = field(default_factory=frozenset)
leader_of: frozenset[int] = field(default_factory=frozenset)
reason: str = "" # 仅用于日志与 /auth/me 展示super_admin / grouped / supervisor_default / ungrouped
# ---- 语义查询 ----
@property
def is_unrestricted(self) -> bool:
"""不限范围(全厂)。注意与 is_empty 语义相反,别混用。"""
return self.phases is None
@property
def is_empty(self) -> bool:
"""有范围但范围为空 —— 什么都看不到。"""
return self.phases is not None and not self.phases
def allows_phase(self, phase: str | None) -> bool:
"""Python 侧判定(需要按单个产品/任务判断时用,与 SQL 侧同口径)"""
if self.phases is None:
return True
return (phase or LIFECYCLE_PRODUCTION) in self.phases
# ---- SQL 谓词 ----
def product_where(self):
"""Product 为主体FROM products或已 JOIN 到 products的语句用。
⚠️ 空集合必须显式 false()。SQLAlchemy 对 `in_(())` 生成 `IN (NULL)`
并抛 SAWarning且历史版本行为有过变化 —— 一旦退化成不过滤就是
全量泄漏,这是本方法存在的核心理由。
"""
if self.phases is None:
return true()
if not self.phases:
return false()
return Product.lifecycle_phase.in_(tuple(sorted(self.phases)))
def task_where(self):
"""Task 为主体的语句用。
⚠️ 前置条件:调用方**必须已经** join 了 Product
`.join(Product, Task.product_id == Product.id)`)。
tasks 表本身没有 lifecycle_phase漏 join 会变成笛卡尔积或直接报错。
谓词内容与 product_where 相同,**分开命名只为把这个无法用类型系统
表达的前置条件写在调用点** —— `grep "task_where"` 就能一次找出所有
需要检查 join 的地方。
"""
return self.product_where()
async def resolve_data_scope(db: AsyncSession, current_user: dict) -> DataScope:
"""从 JWT payload 解析数据范围。
规则(顺序即优先级,改动前先跟业务确认):
1. `SUPER_ADMIN` → 永远全厂。**硬编码,不可被分组覆盖** —— 防止管理员
被误分进某个组后突然失去全部视野。
2. 被显式分进组 → 该组生效。**分组优先于角色**:连 SUPERVISOR 被分进组
也会受限(这是刻意的,见 groups.py 为何只有超管能改分组)。
3. 未分组的 `SUPERVISOR`(部门主管)→ 默认全厂。
4. 未分组的普通用户 → 空范围,或按 DATA_SCOPE_UNGROUPED 开关过渡放行。
"""
role = (current_user or {}).get("role") or ""
username = (current_user or {}).get("username") or ""
# ---- 规则 1超管硬放行 ----
if role == SUPER_ADMIN:
return DataScope(phases=None, reason="super_admin")
# ---- 规则 2查我所属的活跃组走 ix_business_group_members_user_id----
rows = []
if username:
result = await db.execute(
select(
BusinessGroup.id,
BusinessGroup.parent_id,
BusinessGroupMember.is_leader,
)
.join(BusinessGroup, BusinessGroup.id == BusinessGroupMember.group_id)
.where(
BusinessGroupMember.user_id == username,
BusinessGroup.is_active.is_(True), # 停用组等同不存在
)
)
rows = result.all()
if rows:
# 范围继承:小组自己配了就用小组的,没配则向上取父组的。
# 这样「生产大组配一次 PRODUCTION下面的生产/测试小组都不用再配」。
lookup_ids = {r[0] for r in rows} | {r[1] for r in rows if r[1]}
phase_rows = await db.execute(
select(BusinessGroupPhase.group_id, BusinessGroupPhase.phase)
.where(BusinessGroupPhase.group_id.in_(lookup_ids))
)
by_group: dict[int, set[str]] = defaultdict(set)
for gid, ph in phase_rows.all():
if ph:
by_group[gid].add(ph)
phases: set[str] = set()
for gid, parent_id, _is_leader in rows:
own = by_group.get(gid)
if own:
phases |= own
elif parent_id:
phases |= by_group.get(parent_id, set())
return DataScope(
phases=frozenset(phases),
group_ids=frozenset(r[0] for r in rows),
leader_of=frozenset(r[0] for r in rows if r[2]),
reason="grouped",
)
# ---- 规则 3未分组的主管 → 默认全厂 ----
if role == SUPERVISOR:
return DataScope(phases=None, reason="supervisor_default")
# ---- 规则 4未分组的普通用户 ----
if settings.DATA_SCOPE_UNGROUPED.upper() == "NONE":
return DataScope(phases=frozenset(), reason="ungrouped")
# 过渡期:先放行,但把「谁还没分组」记下来 —— 这是把开关安全翻到 NONE 的前提
logger.warning(
"data_scope.ungrouped过渡期默认放行请尽快完成分组",
extra={"extra_fields": {"user": username, "role": role}},
)
return DataScope(phases=None, reason="ungrouped_fallback")
async def scope_group_names(db: AsyncSession, scope: DataScope) -> list[str]:
"""当前范围对应的组显示名 —— 供 /auth/me 让前端展示「我为什么只看到这些」"""
if not scope.group_ids:
return []
result = await db.execute(
select(BusinessGroup.name)
.where(BusinessGroup.id.in_(tuple(scope.group_ids)))
.order_by(BusinessGroup.sort_order, BusinessGroup.id)
)
return [r[0] for r in result.all()]
def scope_phase_labels(scope: DataScope) -> list[str]:
"""范围的中文标签。
刻意由服务端下发,避免前端再抄一份 phase 词表 —— 前端已有
constants/task.ts 与 track-uniapp/utils/lifecycle.js 两份副本,
不要再加第三份。
"""
if scope.phases is None:
return ["全厂"]
if not scope.phases:
return ["未分组"]
return [PHASE_LABELS.get(p, p) for p in sorted(scope.phases)]

View File

@ -19,6 +19,8 @@ from app.models.production_order import ProductionOrder
from app.models.task import Task
from app.schemas.product import ProductCreate, ProductUpdate, ProductResponse, ProductScanResponse
from app.schemas.task import TaskSummaryResponse, TaskResponse, TaskRecordResponse
# 业务分组数据范围 —— 只借用类型,谓词一律由 DataScope 生成,不在这里手写 IN 条件
from app.services.data_scope_service import DataScope
def _task_to_response(task: Task) -> TaskResponse:
@ -62,7 +64,21 @@ async def _load_task_tree(db: AsyncSession, product_id: uuid.UUID) -> list[TaskR
async def get_product_by_serial(db: AsyncSession, serial_number: str) -> ProductScanResponse:
"""扫码查询:根据 16 位序列号查出产品 + 所属订单 + 完整任务树"""
"""扫码查询:根据 16 位序列号查出产品 + 所属订单 + 完整任务树
⚠️ 本函数**刻意不接受 DataScope、也不做业务分组过滤** —— 这是与
「列表按组过滤」同等重要的设计决策,不是漏改:
· 维修组扫到一台生产中的设备必须能【看】。现场要判断的恰恰是
「这台机器是不是走错了流程 / 该不该到我这儿」,看不见就无法判断。
· 「不能操作」由操作类接口各自的属主校验task_service._check_permission
保证,不在查询层做。
· 在这里加范围过滤会让维修工扫自己的设备都可能 404 —— 功能性倒退。
· 本函数另有 3 个内部调用点product_finalize_service 两处、
product_service 内部一处),加必传参数会波及它们。
如需调整这个决策,请先跟业务确认「跨组扫码要能看」这条是否仍然成立。
"""
result = await db.execute(
select(Product)
.options(
@ -524,6 +540,7 @@ def _lookup_display_names(location_ids: list[str]) -> dict[str, str]:
async def get_all_products(
db: AsyncSession,
scope: DataScope,
skip: int = 0,
limit: int = 50,
keyword: str | None = None,
@ -534,6 +551,9 @@ async def get_all_products(
keyword: 同时模糊匹配 serial_number (产品身份证)、material_name/id (规格型号)、order_no (订单号)
status_filter: 按产品状态过滤 (如 PENDING / WIP / COMPLETED / ARCHIVED)
⚠️ scope **不给默认值**:它是业务分组的权限边界,漏传必须当场 TypeError
而不是悄悄退化成「不过滤」——那等于全量泄漏。
"""
stmt = select(Product).options(selectinload(Product.order))
@ -620,6 +640,14 @@ async def get_all_products(
# 兜底:其他状态码按"存在该状态任务"匹配(未完结)
stmt = stmt.where(not_finished, _has_task_status(sf))
# 🚀 业务分组数据范围过滤。
# 位置很关键:必须加在 offset/limit **之前**,因为下面有 6 段基于
# product_ids 的批量预计算macro_status / 最新任务 / 主干工序 / 滞留时长 /
# 生产天数),过滤晚了等于把这些算完再丢掉,纯浪费。
# 注:上面的 keyword 分支带了 .distinct(),在其后追加 .where() 语义正确
# distinct 是整体修饰,不是"立即去重")。
stmt = stmt.where(scope.product_where())
stmt = stmt.offset(skip).limit(limit).order_by(Product.created_at.desc())
result = await db.execute(stmt)

View File

@ -23,6 +23,8 @@ from app.core.lifecycle import (
from app.models.product import Product
from app.models.task_log import TaskLog
from app.core.roles import ADMIN_ROLES
# 业务分组数据范围 —— 只借用类型,谓词一律由 DataScope 生成,不在这里手写 IN 条件
from app.services.data_scope_service import DataScope
from app.schemas.task import (
TaskCreate,
TaskUpdate,
@ -455,22 +457,38 @@ async def update_task(db: AsyncSession, task_id: uuid.UUID, data: TaskUpdate) ->
async def get_all_tasks(
db: AsyncSession, product_id: uuid.UUID | None = None,
db: AsyncSession, scope: DataScope,
product_id: uuid.UUID | None = None,
assignee_id: str | None = None, skip: int = 0, limit: int = 50
) -> TaskListResponse:
"""获取任务列表,可按产品/负责人筛选"""
"""获取任务列表,可按产品/负责人筛选
⚠️ tasks 表**没有** lifecycle_phase —— 业务分组的范围判定必须 join(Product)。
所以仅在 scope 受限时补 join不限范围超管时不加避免给每次请求
白搭一个 join。
⚠️ scope **不给默认值**:它是权限边界,漏传要当场 TypeError不能静默全量。
"""
filters = []
if product_id:
filters.append(Task.product_id == product_id)
if assignee_id:
filters.append(Task.assignee_id == assignee_id)
# 🚀 数据范围谓词进【共享的 filters】—— 下面 count 与 select 是两条独立语句,
# 谓词只写一次就天然同时生效。若只给其中一条加过滤total 会与实际页内容
# 不一致,移动端 hasMore靠 tasks.length < total判断跟着错。
needs_join = scope.phases is not None
if needs_join:
filters.append(scope.task_where())
# 总数必须独立 COUNT移动端「我的任务」用 total 判断 hasMore
# tasks.length < total若 total 取当前页条数,首页满员时
# hasMore 恒为 false列表永远停在第一页。
total = await db.scalar(
select(func.count()).select_from(Task).where(*filters)
) or 0
total_stmt = select(func.count()).select_from(Task)
if needs_join:
total_stmt = total_stmt.join(Product, Task.product_id == Product.id)
total = await db.scalar(total_stmt.where(*filters)) or 0
stmt = (
select(Task)
@ -483,6 +501,8 @@ async def get_all_tasks(
.limit(limit)
.order_by(Task.created_at.desc())
)
if needs_join:
stmt = stmt.join(Product, Task.product_id == Product.id)
result = await db.execute(stmt)
tasks = result.scalars().all()