role_service.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
  1. """角色 CRUD、权限矩阵与用户关联维护。"""
  2. from __future__ import annotations
  3. from datetime import datetime, timezone
  4. from typing import Any
  5. from sqlalchemy import func, select
  6. from sqlalchemy.exc import IntegrityError
  7. from dms.common.enums import (
  8. AuditAction,
  9. AuditTarget,
  10. DeptScope,
  11. EnabledStatus,
  12. RoleCode,
  13. SecurityLevel,
  14. )
  15. from dms.common.errors import (
  16. ConflictError,
  17. InvalidArgumentError,
  18. ResourceNotFoundError,
  19. RoleInUseError,
  20. )
  21. from dms.common.pagination import PageRequest, page_result
  22. from dms.common.response import serialize_id
  23. from dms.database.transaction import transaction
  24. from dms.extensions import db
  25. from dms.models import Organization, Role, RolePermission, User
  26. from dms.security.auth_context import get_auth_context
  27. from dms.services.admin_permission import (
  28. MANAGED_ORG_SELF,
  29. MANAGED_ORG_SELF_ANCHOR,
  30. SUPER_ADMIN_USERNAME,
  31. )
  32. from dms.services.audit_service import business_audit
  33. ROLE_CREATE_FIELDS = {
  34. "roleName",
  35. "sortNo",
  36. "status",
  37. "remark",
  38. "isDefault",
  39. "manageCategoryEdit",
  40. "manageOrgEdit",
  41. "manageUserEdit",
  42. "manageSystemManage",
  43. "manageAuditLog",
  44. "manageRoleEdit",
  45. "managedOrgId",
  46. }
  47. ROLE_UPDATE_FIELDS = {
  48. "roleName",
  49. "sortNo",
  50. "status",
  51. "remark",
  52. "isDefault",
  53. "rowVersion",
  54. "manageCategoryEdit",
  55. "manageOrgEdit",
  56. "manageUserEdit",
  57. "manageSystemManage",
  58. "manageAuditLog",
  59. "manageRoleEdit",
  60. "managedOrgId",
  61. }
  62. ROLE_PERMISSION_FIELDS = {"matrix", "rowVersion"}
  63. ROLE_USER_FIELDS = {"userIds", "rowVersion"}
  64. # 矩阵 8 个操作布尔位(契约1.31:canUpload 仅 OWN 行有意义,OTHER 行恒false;
  65. # canBind/canUnbind 界面合并为单一"挂载"开关,两列恒同值)
  66. PERMISSION_FIELDS: tuple[tuple[str, str], ...] = (
  67. ("canView", "can_view"),
  68. ("canUpload", "can_upload"),
  69. ("canDownload", "can_download"),
  70. ("canEdit", "can_edit"),
  71. ("canDelete", "can_delete"),
  72. ("canConfigPermission", "can_config_permission"),
  73. ("canBindAttachment", "can_bind_attachment"),
  74. ("canUnbindAttachment", "can_unbind_attachment"),
  75. )
  76. MATRIX_KEYS = {
  77. (level.value, scope.value)
  78. for level in SecurityLevel
  79. for scope in DeptScope
  80. }
  81. def _utc_now() -> datetime:
  82. return datetime.now(timezone.utc).replace(tzinfo=None)
  83. def _required_text(
  84. value: Any,
  85. *,
  86. field: str,
  87. max_length: int,
  88. ) -> str:
  89. if not isinstance(value, str) or not value.strip():
  90. raise InvalidArgumentError(f"{field}不能为空")
  91. normalized = value.strip()
  92. if len(normalized) > max_length:
  93. raise InvalidArgumentError(f"{field}长度不能超过{max_length}")
  94. return normalized
  95. def _optional_text(value: Any, *, field: str, max_length: int) -> str | None:
  96. if value is None:
  97. return None
  98. if not isinstance(value, str):
  99. raise InvalidArgumentError(f"{field}必须是字符串")
  100. normalized = value.strip()
  101. if len(normalized) > max_length:
  102. raise InvalidArgumentError(f"{field}长度不能超过{max_length}")
  103. return normalized or None
  104. def _sort_no(value: Any) -> int:
  105. if type(value) is not int or value < 0 or value > 999999:
  106. raise InvalidArgumentError("sortNo必须是0~999999的整数")
  107. return value
  108. def _status_value(value: Any) -> str:
  109. if not isinstance(value, str):
  110. raise InvalidArgumentError("status必须是字符串")
  111. try:
  112. return EnabledStatus(value).value
  113. except ValueError as exc:
  114. raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
  115. def _boolean(value: Any, *, field: str) -> bool:
  116. if type(value) is not bool:
  117. raise InvalidArgumentError(f"{field}必须是布尔值")
  118. return value
  119. def _optional_org_id(value: Any) -> int | None:
  120. """managedOrgId 解析:空串/NULL 视为全局,"SELF" 为跟随用户所在部门
  121. 哨兵(落库 0),否则须指向未删除机构。"""
  122. if value is None or value == "":
  123. return None
  124. if value == MANAGED_ORG_SELF:
  125. return MANAGED_ORG_SELF_ANCHOR
  126. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  127. raise InvalidArgumentError("managedOrgId必须是正整数形式的字符串ID")
  128. org_id = int(value)
  129. exists = db.session.scalar(
  130. select(Organization.id).where(
  131. Organization.id == org_id,
  132. Organization.is_deleted.is_(False),
  133. )
  134. )
  135. if exists is None:
  136. raise InvalidArgumentError("managedOrgId必须指向有效机构")
  137. return org_id
  138. def _managed_org_value(org_id: int | None) -> str | None:
  139. """锚点序列化:SELF 哨兵(0)还原为 "SELF",其余按机构ID。"""
  140. if org_id == MANAGED_ORG_SELF_ANCHOR:
  141. return MANAGED_ORG_SELF
  142. return serialize_id(org_id)
  143. def _row_version(value: Any) -> int:
  144. if type(value) is not int or value < 0:
  145. raise InvalidArgumentError("rowVersion必须是非负整数")
  146. return value
  147. def _id_list(value: Any, *, field: str) -> list[int]:
  148. if not isinstance(value, list) or any(
  149. not isinstance(item, str) or not item.isdecimal() or int(item) <= 0
  150. for item in value
  151. ):
  152. raise InvalidArgumentError(f"{field}必须是正整数形式的字符串ID数组")
  153. ids = [int(item) for item in value]
  154. if len(set(ids)) != len(ids):
  155. raise InvalidArgumentError(f"{field}不能包含重复ID")
  156. return ids
  157. def _exact_payload(payload: Any, expected: set[str]) -> dict[str, Any]:
  158. if not isinstance(payload, dict) or set(payload) != expected:
  159. raise InvalidArgumentError(
  160. "请求体字段必须且只能包含:" + "、".join(sorted(expected))
  161. )
  162. return payload
  163. def _role_row(session, role_id: int, *, for_update: bool = False) -> Role:
  164. stmt = select(Role).where(Role.id == role_id, Role.is_deleted.is_(False))
  165. if for_update:
  166. stmt = stmt.with_for_update()
  167. role = session.scalar(stmt)
  168. if role is None:
  169. raise ResourceNotFoundError("角色不存在")
  170. return role
  171. def _member_counts(session, role_ids: list[int]) -> dict[int, int]:
  172. if not role_ids:
  173. return {}
  174. rows = session.execute(
  175. select(User.role_id, func.count(User.id))
  176. .where(User.role_id.in_(role_ids), User.is_deleted.is_(False))
  177. .group_by(User.role_id)
  178. ).all()
  179. return {role_id: count for role_id, count in rows}
  180. def role_summary(role: Role, member_count: int) -> dict[str, object]:
  181. return {
  182. "id": serialize_id(role.id),
  183. "roleName": role.role_name,
  184. "sortNo": role.sort_no,
  185. "status": role.status,
  186. "remark": role.remark,
  187. "isDefault": role.is_default,
  188. "manageCategoryEdit": bool(role.manage_category_edit),
  189. "manageOrgEdit": bool(role.manage_org_edit),
  190. "manageUserEdit": bool(role.manage_user_edit),
  191. "manageSystemManage": bool(role.manage_system),
  192. "manageAuditLog": bool(role.manage_audit_log),
  193. "manageRoleEdit": bool(role.manage_role_edit),
  194. "managedOrgId": _managed_org_value(role.managed_org_id),
  195. "memberCount": member_count,
  196. "rowVersion": role.row_version,
  197. }
  198. def list_roles(
  199. *,
  200. keyword: str | None,
  201. status: str | None,
  202. page_request: PageRequest,
  203. ) -> dict[str, object]:
  204. filters = [Role.is_deleted.is_(False)]
  205. if keyword:
  206. filters.append(Role.role_name.like(f"%{keyword.strip()}%"))
  207. if status:
  208. try:
  209. normalized_status = EnabledStatus(status).value
  210. except ValueError as exc:
  211. raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
  212. filters.append(Role.status == normalized_status)
  213. total = db.session.scalar(select(func.count(Role.id)).where(*filters)) or 0
  214. roles = db.session.scalars(
  215. select(Role)
  216. .where(*filters)
  217. .order_by(Role.sort_no.asc(), Role.id.asc())
  218. .offset(page_request.offset)
  219. .limit(page_request.page_size)
  220. ).all()
  221. counts = _member_counts(db.session, [role.id for role in roles])
  222. return page_result(
  223. [role_summary(role, counts.get(role.id, 0)) for role in roles],
  224. page=page_request.page,
  225. page_size=page_request.page_size,
  226. total=total,
  227. )
  228. def _clear_other_defaults(session, *, keep_id: int | None) -> None:
  229. """将除 keep_id 外的活跃默认角色取消默认标记(先清后设避免唯一约束冲突)。"""
  230. stmt = select(Role).where(
  231. Role.is_default.is_(True),
  232. Role.is_deleted.is_(False),
  233. )
  234. if keep_id is not None:
  235. stmt = stmt.where(Role.id != keep_id)
  236. actor_id = get_auth_context().user_id
  237. now = _utc_now()
  238. for existing in session.scalars(stmt).all():
  239. existing.is_default = False
  240. existing.updated_by = actor_id
  241. existing.updated_at = now
  242. existing.row_version += 1
  243. def _ensure_role_name_free(session, role_name: str, *, exclude_id: int) -> None:
  244. duplicated = session.scalar(
  245. select(Role.id).where(
  246. Role.role_name == role_name,
  247. Role.is_deleted.is_(False),
  248. Role.id != exclude_id,
  249. )
  250. )
  251. if duplicated is not None:
  252. raise ConflictError("角色名已存在")
  253. def _seed_permission_rows(session, role_id: int, actor_id: int | None) -> None:
  254. for level in SecurityLevel:
  255. for scope in DeptScope:
  256. session.add(
  257. RolePermission(
  258. role_id=role_id,
  259. security_level=level.value,
  260. dept_scope=scope.value,
  261. created_by=actor_id,
  262. updated_by=actor_id,
  263. )
  264. )
  265. def create_role(payload: Any) -> dict[str, object]:
  266. body = _exact_payload(payload, ROLE_CREATE_FIELDS)
  267. role_name = _required_text(body["roleName"], field="roleName", max_length=64)
  268. sort_no = _sort_no(body["sortNo"])
  269. status = _status_value(body["status"])
  270. remark = _optional_text(body["remark"], field="remark", max_length=255)
  271. is_default = _boolean(body["isDefault"], field="isDefault")
  272. manage_category = _boolean(body["manageCategoryEdit"], field="manageCategoryEdit")
  273. manage_org = _boolean(body["manageOrgEdit"], field="manageOrgEdit")
  274. manage_user = _boolean(body["manageUserEdit"], field="manageUserEdit")
  275. manage_system = _boolean(body["manageSystemManage"], field="manageSystemManage")
  276. manage_audit_log = _boolean(body["manageAuditLog"], field="manageAuditLog")
  277. manage_role_edit = _boolean(body["manageRoleEdit"], field="manageRoleEdit")
  278. managed_org_id = _optional_org_id(body["managedOrgId"])
  279. actor_id = get_auth_context().user_id
  280. try:
  281. with transaction() as session:
  282. _ensure_role_name_free(session, role_name, exclude_id=0)
  283. if is_default:
  284. _clear_other_defaults(session, keep_id=None)
  285. session.flush()
  286. role = Role(
  287. role_name=role_name,
  288. sort_no=sort_no,
  289. status=status,
  290. remark=remark,
  291. is_default=is_default,
  292. manage_category_edit=manage_category,
  293. manage_org_edit=manage_org,
  294. manage_user_edit=manage_user,
  295. manage_system=manage_system,
  296. manage_audit_log=manage_audit_log,
  297. manage_role_edit=manage_role_edit,
  298. managed_org_id=managed_org_id,
  299. created_by=actor_id,
  300. updated_by=actor_id,
  301. )
  302. session.add(role)
  303. session.flush()
  304. _seed_permission_rows(session, role.id, actor_id)
  305. session.add(
  306. business_audit(
  307. action=AuditAction.CREATE_ROLE,
  308. target=AuditTarget.ROLE,
  309. target_id=role.id,
  310. target_name=role.role_name,
  311. detail=_role_detail(role),
  312. )
  313. )
  314. return role_summary(role, 0)
  315. except IntegrityError as exc:
  316. raise ConflictError("角色名已存在或默认角色设置冲突") from exc
  317. def _role_detail(role: Role) -> dict[str, object]:
  318. return {
  319. "roleName": role.role_name,
  320. "sortNo": role.sort_no,
  321. "status": role.status,
  322. "remark": role.remark,
  323. "isDefault": role.is_default,
  324. "manageCategoryEdit": bool(role.manage_category_edit),
  325. "manageOrgEdit": bool(role.manage_org_edit),
  326. "manageUserEdit": bool(role.manage_user_edit),
  327. "manageSystemManage": bool(role.manage_system),
  328. "manageAuditLog": bool(role.manage_audit_log),
  329. "manageRoleEdit": bool(role.manage_role_edit),
  330. "managedOrgId": _managed_org_value(role.managed_org_id),
  331. }
  332. def update_role(role_id: int, payload: Any) -> dict[str, object]:
  333. body = _exact_payload(payload, ROLE_UPDATE_FIELDS)
  334. role_name = _required_text(body["roleName"], field="roleName", max_length=64)
  335. sort_no = _sort_no(body["sortNo"])
  336. status = _status_value(body["status"])
  337. remark = _optional_text(body["remark"], field="remark", max_length=255)
  338. is_default = _boolean(body["isDefault"], field="isDefault")
  339. manage_category = _boolean(body["manageCategoryEdit"], field="manageCategoryEdit")
  340. manage_org = _boolean(body["manageOrgEdit"], field="manageOrgEdit")
  341. manage_user = _boolean(body["manageUserEdit"], field="manageUserEdit")
  342. manage_system = _boolean(body["manageSystemManage"], field="manageSystemManage")
  343. manage_audit_log = _boolean(body["manageAuditLog"], field="manageAuditLog")
  344. manage_role_edit = _boolean(body["manageRoleEdit"], field="manageRoleEdit")
  345. managed_org_id = _optional_org_id(body["managedOrgId"])
  346. expected_version = _row_version(body["rowVersion"])
  347. actor_id = get_auth_context().user_id
  348. now = _utc_now()
  349. with transaction() as session:
  350. role = _role_row(session, role_id, for_update=True)
  351. if role.row_version != expected_version:
  352. raise ConflictError(
  353. "数据已被其他用户修改,请刷新后重试",
  354. details={"currentRowVersion": role.row_version},
  355. )
  356. _ensure_role_name_free(session, role_name, exclude_id=role.id)
  357. before = _role_detail(role)
  358. # 新设默认:先清旧默认,避免 default_marker 唯一约束冲突
  359. if is_default and not role.is_default:
  360. _clear_other_defaults(session, keep_id=role.id)
  361. session.flush()
  362. role.role_name = role_name
  363. role.sort_no = sort_no
  364. role.status = status
  365. role.remark = remark
  366. role.is_default = is_default
  367. role.manage_category_edit = manage_category
  368. role.manage_org_edit = manage_org
  369. role.manage_user_edit = manage_user
  370. role.manage_system = manage_system
  371. role.manage_audit_log = manage_audit_log
  372. role.manage_role_edit = manage_role_edit
  373. role.managed_org_id = managed_org_id
  374. role.updated_by = actor_id
  375. role.updated_at = now
  376. role.row_version += 1
  377. after = _role_detail(role)
  378. session.add(
  379. business_audit(
  380. action=AuditAction.EDIT_ROLE,
  381. target=AuditTarget.ROLE,
  382. target_id=role.id,
  383. target_name=role.role_name,
  384. detail={"before": before, "after": after},
  385. )
  386. )
  387. member_count = _member_counts(db.session, [role.id]).get(role.id, 0)
  388. return role_summary(role, member_count)
  389. def delete_role(role_id: int, row_version: int) -> dict[str, object]:
  390. actor_id = get_auth_context().user_id
  391. now = _utc_now()
  392. with transaction() as session:
  393. role = _role_row(session, role_id, for_update=True)
  394. if role.row_version != row_version:
  395. raise ConflictError(
  396. "数据已被其他用户修改,请刷新后重试",
  397. details={"currentRowVersion": role.row_version},
  398. )
  399. bound_users = (
  400. session.scalar(
  401. select(func.count(User.id)).where(
  402. User.role_id == role_id,
  403. User.is_deleted.is_(False),
  404. )
  405. )
  406. or 0
  407. )
  408. if bound_users:
  409. raise RoleInUseError(
  410. details={"memberCount": bound_users},
  411. )
  412. target_name = role.role_name
  413. role.is_deleted = True
  414. role.deleted_at = now
  415. role.updated_by = actor_id
  416. role.updated_at = now
  417. role.row_version += 1
  418. # 矩阵行随角色软删,保留审计追溯
  419. for row in session.scalars(
  420. select(RolePermission).where(
  421. RolePermission.role_id == role_id,
  422. RolePermission.is_deleted.is_(False),
  423. )
  424. ).all():
  425. row.is_deleted = True
  426. row.deleted_at = now
  427. row.updated_by = actor_id
  428. row.updated_at = now
  429. session.add(
  430. business_audit(
  431. action=AuditAction.DELETE_ROLE,
  432. target=AuditTarget.ROLE,
  433. target_id=role_id,
  434. target_name=target_name,
  435. detail={"roleName": target_name, "rowVersion": role.row_version},
  436. )
  437. )
  438. return {"id": serialize_id(role_id), "deleted": True}
  439. def _permission_row_dto(row: RolePermission) -> dict[str, object]:
  440. dto: dict[str, object] = {
  441. "securityLevel": row.security_level,
  442. "deptScope": row.dept_scope,
  443. }
  444. for name, attr in PERMISSION_FIELDS:
  445. dto[name] = getattr(row, attr)
  446. return dto
  447. def list_role_permissions(role_id: int) -> list[dict[str, object]]:
  448. _role_row(db.session, role_id)
  449. rows = db.session.scalars(
  450. select(RolePermission)
  451. .where(
  452. RolePermission.role_id == role_id,
  453. RolePermission.is_deleted.is_(False),
  454. )
  455. .order_by(RolePermission.id.asc())
  456. ).all()
  457. return [_permission_row_dto(row) for row in rows]
  458. def _validate_matrix(value: Any) -> list[dict[str, Any]]:
  459. expected_row_fields = {"securityLevel", "deptScope"} | {
  460. name for name, _ in PERMISSION_FIELDS
  461. }
  462. if not isinstance(value, list) or len(value) != len(MATRIX_KEYS):
  463. raise InvalidArgumentError(f"权限矩阵必须为{len(MATRIX_KEYS)}行")
  464. rows: dict[tuple[str, str], dict[str, Any]] = {}
  465. for item in value:
  466. if not isinstance(item, dict) or set(item) != expected_row_fields:
  467. raise InvalidArgumentError(
  468. "矩阵行字段必须且只能包含:" + "、".join(sorted(expected_row_fields))
  469. )
  470. key = (item["securityLevel"], item["deptScope"])
  471. if key not in MATRIX_KEYS:
  472. raise InvalidArgumentError("矩阵行的密级或部门范围不是有效枚举值")
  473. if key in rows:
  474. raise InvalidArgumentError("矩阵行不能重复")
  475. for name, _ in PERMISSION_FIELDS:
  476. if type(item[name]) is not bool:
  477. raise InvalidArgumentError(f"{name}必须是布尔值")
  478. # 上传权限仅限本部门(OWN)行:其他部门行恒不可选(契约1.31)
  479. if item["deptScope"] == DeptScope.OTHER.value and item["canUpload"]:
  480. raise InvalidArgumentError("上传权限仅可设置本部门方案")
  481. rows[key] = item
  482. return list(value)
  483. def update_role_permissions(role_id: int, payload: Any) -> dict[str, object]:
  484. body = _exact_payload(payload, ROLE_PERMISSION_FIELDS)
  485. matrix = _validate_matrix(body["matrix"])
  486. expected_version = _row_version(body["rowVersion"])
  487. actor_id = get_auth_context().user_id
  488. now = _utc_now()
  489. with transaction() as session:
  490. role = _role_row(session, role_id, for_update=True)
  491. if role.row_version != expected_version:
  492. raise ConflictError(
  493. "数据已被其他用户修改,请刷新后重试",
  494. details={"currentRowVersion": role.row_version},
  495. )
  496. existing_rows = session.scalars(
  497. select(RolePermission).where(
  498. RolePermission.role_id == role_id,
  499. RolePermission.is_deleted.is_(False),
  500. )
  501. ).all()
  502. by_key = {(row.security_level, row.dept_scope): row for row in existing_rows}
  503. if set(by_key) != MATRIX_KEYS:
  504. raise ConflictError("角色权限矩阵数据不完整,请联系管理员核查")
  505. changed_keys: list[dict[str, object]] = []
  506. for item in matrix:
  507. row = by_key[(item["securityLevel"], item["deptScope"])]
  508. before_flags = {
  509. name: getattr(row, attr) for name, attr in PERMISSION_FIELDS
  510. }
  511. after_flags = {name: item[name] for name, _ in PERMISSION_FIELDS}
  512. if before_flags != after_flags:
  513. row.updated_by = actor_id
  514. row.updated_at = now
  515. for name, attr in PERMISSION_FIELDS:
  516. setattr(row, attr, item[name])
  517. changed_keys.append(
  518. {
  519. "securityLevel": row.security_level,
  520. "deptScope": row.dept_scope,
  521. "before": before_flags,
  522. "after": after_flags,
  523. }
  524. )
  525. role.updated_by = actor_id
  526. role.updated_at = now
  527. role.row_version += 1
  528. session.add(
  529. business_audit(
  530. action=AuditAction.CHANGE_ROLE_PERMISSION,
  531. target=AuditTarget.ROLE,
  532. target_id=role_id,
  533. target_name=role.role_name,
  534. detail={"scope": "matrix", "changedRows": changed_keys},
  535. )
  536. )
  537. return {
  538. "id": serialize_id(role_id),
  539. "matrix": list_role_permissions(role_id),
  540. "rowVersion": role.row_version,
  541. }
  542. def list_role_users(role_id: int) -> dict[str, object]:
  543. _role_row(db.session, role_id)
  544. user_ids = db.session.scalars(
  545. select(User.id)
  546. .where(User.role_id == role_id, User.is_deleted.is_(False))
  547. .order_by(User.id.asc())
  548. ).all()
  549. return {"id": serialize_id(role_id), "userIds": [serialize_id(v) for v in user_ids]}
  550. def update_role_users(role_id: int, payload: Any) -> dict[str, object]:
  551. body = _exact_payload(payload, ROLE_USER_FIELDS)
  552. user_ids = _id_list(body["userIds"], field="userIds")
  553. expected_version = _row_version(body["rowVersion"])
  554. actor_id = get_auth_context().user_id
  555. now = _utc_now()
  556. with transaction() as session:
  557. role = _role_row(session, role_id, for_update=True)
  558. if role.row_version != expected_version:
  559. raise ConflictError(
  560. "数据已被其他用户修改,请刷新后重试",
  561. details={"currentRowVersion": role.row_version},
  562. )
  563. target_ids = set(user_ids)
  564. found: dict[int, User] = {}
  565. if target_ids:
  566. for user in session.scalars(
  567. select(User)
  568. .where(User.id.in_(target_ids), User.is_deleted.is_(False))
  569. .with_for_update()
  570. ).all():
  571. found[user.id] = user
  572. if set(found) != target_ids:
  573. raise InvalidArgumentError("userIds包含无效用户")
  574. for user in found.values():
  575. if user.username == SUPER_ADMIN_USERNAME:
  576. raise InvalidArgumentError("内置admin账号不允许关联自定义角色")
  577. if user.role_code not in (
  578. RoleCode.USER.value,
  579. RoleCode.ADMIN.value,
  580. ):
  581. raise InvalidArgumentError("仅普通用户或系统管理员可关联自定义角色")
  582. current_users = session.scalars(
  583. select(User)
  584. .where(User.role_id == role_id, User.is_deleted.is_(False))
  585. .with_for_update()
  586. ).all()
  587. added_ids: list[int] = []
  588. removed_ids: list[int] = []
  589. for user in current_users:
  590. if user.id not in target_ids:
  591. user.role_id = None
  592. user.auth_version += 1
  593. user.updated_by = actor_id
  594. user.updated_at = now
  595. user.row_version += 1
  596. removed_ids.append(user.id)
  597. for user_id in sorted(target_ids):
  598. user = found[user_id]
  599. if user.role_id != role_id:
  600. user.role_id = role_id
  601. user.auth_version += 1
  602. user.updated_by = actor_id
  603. user.updated_at = now
  604. user.row_version += 1
  605. added_ids.append(user_id)
  606. role.updated_by = actor_id
  607. role.updated_at = now
  608. role.row_version += 1
  609. session.add(
  610. business_audit(
  611. action=AuditAction.CHANGE_ROLE_USER,
  612. target=AuditTarget.ROLE,
  613. target_id=role_id,
  614. target_name=role.role_name,
  615. detail={
  616. "addedUserIds": [serialize_id(v) for v in added_ids],
  617. "removedUserIds": [serialize_id(v) for v in removed_ids],
  618. },
  619. )
  620. )
  621. return {
  622. "id": serialize_id(role_id),
  623. "userIds": [serialize_id(v) for v in sorted(target_ids)],
  624. "rowVersion": role.row_version,
  625. }
  626. ROLE_REORDER_FIELDS = {"orderedIds"}
  627. def reorder_roles(payload: Any) -> dict[str, object]:
  628. """角色列表拖动重排(契约1.27):全体角色槽位置换,步长10重编号 sort_no。
  629. orderedIds 允许为当前分页视图的子集,被提交角色按原槽位次序互换
  630. (未提交角色相对次序不变),与分类同级重排的置换语义一致;
  631. 权限由路由层 role_edit_required 把关。
  632. """
  633. body = _exact_payload(payload, ROLE_REORDER_FIELDS)
  634. ordered_ids = body["orderedIds"]
  635. if not isinstance(ordered_ids, list) or any(
  636. not isinstance(item, str) or not item.isdecimal() or int(item) <= 0
  637. for item in ordered_ids
  638. ):
  639. raise InvalidArgumentError("orderedIds必须是正整数形式的字符串ID数组")
  640. if len(set(ordered_ids)) != len(ordered_ids):
  641. raise InvalidArgumentError("orderedIds存在重复ID")
  642. if len(ordered_ids) < 2:
  643. raise InvalidArgumentError("orderedIds必须是至少包含2个ID的数组")
  644. actor_id = get_auth_context().user_id
  645. with transaction() as session:
  646. rows = session.scalars(
  647. select(Role)
  648. .where(Role.is_deleted.is_(False))
  649. .order_by(Role.sort_no.asc(), Role.id.asc())
  650. .with_for_update()
  651. ).all()
  652. row_by_id = {row.id: row for row in rows}
  653. all_ids = [row.id for row in rows]
  654. ordered = [int(item) for item in ordered_ids]
  655. if any(role_id not in row_by_id for role_id in ordered):
  656. raise InvalidArgumentError("orderedIds包含无效角色")
  657. # 槽位置换:被提交ID按原槽位顺序排好,依次填入目标顺序
  658. slot_ids = sorted(ordered, key=all_ids.index)
  659. new_order = list(all_ids)
  660. for position, role_id in enumerate(slot_ids):
  661. new_order[all_ids.index(role_id)] = ordered[position]
  662. now = _utc_now()
  663. reordered_count = 0
  664. for index, role_id in enumerate(new_order):
  665. row = row_by_id[role_id]
  666. target = (index + 1) * 10
  667. if row.sort_no == target:
  668. continue
  669. row.sort_no = target
  670. row.updated_by = actor_id
  671. row.updated_at = now
  672. row.row_version += 1
  673. reordered_count += 1
  674. first = row_by_id[ordered[0]]
  675. session.add(
  676. business_audit(
  677. action=AuditAction.REORDER_ROLE,
  678. target=AuditTarget.ROLE,
  679. target_id=first.id,
  680. target_name=first.role_name,
  681. detail={
  682. "orderedIds": list(ordered_ids),
  683. "reorderedCount": reordered_count,
  684. },
  685. )
  686. )
  687. return {"reorderedCount": reordered_count}