feat(duty): restrict assign and grab to duty staff role

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-17 18:23:20 +08:00
1 parent a20d31de38
commit 0c4a4bf14a
3 files changed
+185 -2

No files matched your search

+3
View File
@@ -17,6 +17,9 @@ LINK_OPEN = "open"
LINK_CLOSED = "closed"
SOURCE_ASSIGN = "assign"
SOURCE_GRAB = "grab"
# 可排班名单角色(sys_role 固定 id;无菜单,仅标签)
DUTY_STAFF_ROLE_ID = "R65CAAE057914CCCAE2A81BE627A5101"
DUTY_STAFF_ROLE_NAME = "值班人员"
class DutyDay(Base):
+76 -2
View File
@@ -6,11 +6,14 @@ import secrets
from datetime import date, datetime
from typing import Any, Dict, List, Optional, Sequence
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.utils import generate_id
from app.models.duty_models import (
DUTY_STAFF_ROLE_ID,
DUTY_STAFF_ROLE_NAME,
LINK_CLOSED,
LINK_OPEN,
SHIFT_LABELS,
@@ -20,7 +23,7 @@ from app.models.duty_models import (
DutyDay,
DutySlot,
)
from app.models.models import SysUser
from app.models.models import SysRole, SysUser, SysUserRole
from app.schemas.duty import DutyDaySaveBody, DutySlotSaveIn
PERSON_NAME_MAX_LEN = 20
@@ -348,6 +351,8 @@ def save_day(
# 若只有 userId,允许 personName 已由上层填好
if not person_name:
raise DutyBizError("指定员工姓名不能为空")
if item.source == SOURCE_ASSIGN and uid:
assert_user_is_duty_staff(db, tenant_no, uid)
db.add(
DutyAssignment(
id=generate_id(),
@@ -375,9 +380,48 @@ def save_day(
return _serialize_day(db, day)
def _list_public_users(db: Session, tenant_no: str) -> List[Dict[str, str]]:
def resolve_duty_staff_role_id(db: Session, tenant_no: str) -> Optional[str]:
"""按固定 id 查找「值班人员」角色;找不到再按角色名兜底。"""
role = (
db.query(SysRole)
.filter(
SysRole.id == DUTY_STAFF_ROLE_ID,
SysRole.tenant_no == tenant_no,
SysRole.del_flag == 0,
)
.first()
)
if role:
return role.id
role = (
db.query(SysRole)
.filter(
SysRole.role_name == DUTY_STAFF_ROLE_NAME,
SysRole.tenant_no == tenant_no,
SysRole.del_flag == 0,
SysRole.status == "1",
)
.first()
)
return role.id if role else None
def list_duty_staff_users(db: Session, tenant_no: str) -> List[Dict[str, str]]:
"""本租户启用且绑定「值班人员」角色的用户(公开页 users / 管理端 options)。"""
role_id = resolve_duty_staff_role_id(db, tenant_no)
if not role_id:
return []
rows = (
db.query(SysUser)
.join(
SysUserRole,
and_(
SysUserRole.user_id == SysUser.id,
SysUserRole.role_id == role_id,
SysUserRole.del_flag == 0,
SysUserRole.tenant_no == tenant_no,
),
)
.filter(
SysUser.tenant_no == tenant_no,
SysUser.del_flag == 0,
@@ -387,7 +431,11 @@ def _list_public_users(db: Session, tenant_no: str) -> List[Dict[str, str]]:
.all()
)
out: List[Dict[str, str]] = []
seen = set()
for r in rows:
if r.id in seen:
continue
seen.add(r.id)
label = (r.nick_name or "").strip() or (r.user_no or "").strip()
if not label:
continue
@@ -395,6 +443,28 @@ def _list_public_users(db: Session, tenant_no: str) -> List[Dict[str, str]]:
return out
def assert_user_is_duty_staff(db: Session, tenant_no: str, user_id: str) -> None:
role_id = resolve_duty_staff_role_id(db, tenant_no)
if not role_id:
raise DutyBizError("未配置「值班人员」角色,请联系管理员")
link = (
db.query(SysUserRole)
.filter(
SysUserRole.user_id == user_id,
SysUserRole.role_id == role_id,
SysUserRole.tenant_no == tenant_no,
SysUserRole.del_flag == 0,
)
.first()
)
if not link:
raise DutyBizError("仅可选择「值班人员」角色的用户")
def _list_public_users(db: Session, tenant_no: str) -> List[Dict[str, str]]:
return list_duty_staff_users(db, tenant_no)
def _resolve_grab_user(db: Session, tenant_no: str, user_id: str) -> SysUser:
uid = (user_id or "").strip()
if not uid:
@@ -411,6 +481,7 @@ def _resolve_grab_user(db: Session, tenant_no: str, user_id: str) -> SysUser:
)
if not user:
raise DutyBizError("用户无效或不存在")
assert_user_is_duty_staff(db, tenant_no, user.id)
return user
@@ -525,4 +596,7 @@ __all__ = [
"soft_delete_assignment",
"get_public_page",
"grab",
"resolve_duty_staff_role_id",
"list_duty_staff_users",
"assert_user_is_duty_staff",
]
+106
View File
@@ -6,6 +6,8 @@ from unittest.mock import MagicMock
import pytest
from app.models.duty_models import (
DUTY_STAFF_ROLE_ID,
DUTY_STAFF_ROLE_NAME,
LINK_CLOSED,
LINK_OPEN,
SHIFT_EVENING,
@@ -16,8 +18,11 @@ from app.models.duty_models import (
from app.schemas.duty import DutyAssignmentIn, DutyDaySaveBody, DutySlotSaveIn
from app.services.duty_schedule import (
DutyBizError,
assert_user_is_duty_staff,
grab,
list_duty_staff_users,
normalize_person_name,
resolve_duty_staff_role_id,
save_day,
)
@@ -72,6 +77,11 @@ def test_grab_rejects_when_full():
slot_q.filter.return_value.with_for_update.return_value.first.return_value = slot
user_q = MagicMock()
user_q.filter.return_value.first.return_value = user
role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
role_q = MagicMock()
role_q.filter.return_value.first.return_value = role
ur_q = MagicMock()
ur_q.filter.return_value.first.return_value = SimpleNamespace(user_id="u1", role_id=DUTY_STAFF_ROLE_ID)
def query_side_effect(model):
name = getattr(model, "__name__", str(model))
@@ -81,6 +91,10 @@ def test_grab_rejects_when_full():
return slot_q
if name == "SysUser":
return user_q
if name == "SysRole":
return role_q
if name == "SysUserRole":
return ur_q
# DutyAssignment count / duplicate
aq = MagicMock()
aq.filter.return_value.count.return_value = 1
@@ -173,6 +187,11 @@ def test_grab_rejects_duplicate_same_shift():
slot_q.filter.return_value.with_for_update.return_value.first.return_value = slot
user_q = MagicMock()
user_q.filter.return_value.first.return_value = user
role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
role_q = MagicMock()
role_q.filter.return_value.first.return_value = role
ur_q = MagicMock()
ur_q.filter.return_value.first.return_value = SimpleNamespace(user_id="u1", role_id=DUTY_STAFF_ROLE_ID)
assign_calls = {"n": 0}
@@ -184,6 +203,10 @@ def test_grab_rejects_duplicate_same_shift():
return slot_q
if name == "SysUser":
return user_q
if name == "SysRole":
return role_q
if name == "SysUserRole":
return ur_q
aq = MagicMock()
# first: duplicate check .first(); later: count
assign_calls["n"] += 1
@@ -269,3 +292,86 @@ def test_save_day_rejects_shrink_below_grab_count():
with pytest.raises(DutyBizError, match="超过名额|缩容|报名"):
save_day(db, "t1", {"user_id": "op1"}, body)
def test_resolve_duty_staff_role_id_by_fixed_id():
db = MagicMock()
role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID, role_name=DUTY_STAFF_ROLE_NAME, del_flag=0)
q = MagicMock()
q.filter.return_value.first.return_value = role
db.query.return_value = q
assert resolve_duty_staff_role_id(db, "shanxia2.0") == DUTY_STAFF_ROLE_ID
def test_list_duty_staff_users_empty_when_no_role():
db = MagicMock()
q = MagicMock()
q.filter.return_value.first.return_value = None # resolve role → None twice
db.query.return_value = q
assert list_duty_staff_users(db, "t1") == []
def test_assert_user_is_duty_staff_rejects_without_role():
db = MagicMock()
role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
role_q = MagicMock()
role_q.filter.return_value.first.return_value = role
ur_q = MagicMock()
ur_q.filter.return_value.first.return_value = None
def query_side_effect(model):
name = getattr(model, "__name__", "")
if name == "SysRole":
return role_q
if name == "SysUserRole":
return ur_q
return MagicMock()
db.query.side_effect = query_side_effect
with pytest.raises(DutyBizError, match="值班人员"):
assert_user_is_duty_staff(db, "t1", "u1")
def test_grab_rejects_non_duty_staff():
db = MagicMock()
day = SimpleNamespace(
id="day1",
tenant_no="t1",
link_status=LINK_OPEN,
duty_date=date(2026, 8, 20),
access_token="tok",
del_flag=0,
)
user = SimpleNamespace(
id="u1",
tenant_no="t1",
nick_name="李四",
user_no="lisi",
status="1",
del_flag=0,
)
day_q = MagicMock()
day_q.filter.return_value.with_for_update.return_value.first.return_value = day
user_q = MagicMock()
user_q.filter.return_value.first.return_value = user
role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
role_q = MagicMock()
role_q.filter.return_value.first.return_value = role
ur_q = MagicMock()
ur_q.filter.return_value.first.return_value = None
def query_side_effect(model):
name = getattr(model, "__name__", "")
if name == "DutyDay":
return day_q
if name == "SysUser":
return user_q
if name == "SysRole":
return role_q
if name == "SysUserRole":
return ur_q
return MagicMock()
db.query.side_effect = query_side_effect
with pytest.raises(DutyBizError, match="值班人员"):
grab(db, "tok", SHIFT_EVENING, "u1")