user_service.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. """用户摘要、组织解析、列表查询与用户维护写操作。"""
  2. from __future__ import annotations
  3. from datetime import UTC, datetime, timezone
  4. from typing import Any
  5. from sqlalchemy import func, or_, select
  6. from sqlalchemy.exc import IntegrityError
  7. from dms.common.enums import (
  8. AllowedModule,
  9. AuditAction,
  10. AuditTarget,
  11. EnabledStatus,
  12. RoleCode,
  13. SecurityLevel,
  14. )
  15. from dms.common.errors import (
  16. ConflictError,
  17. InvalidArgumentError,
  18. ResourceNotFoundError,
  19. UserSelfProtectionError,
  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, User
  26. from dms.security.auth_context import get_auth_context
  27. from dms.security.passwords import hash_password
  28. from dms.services.audit_service import business_audit
  29. ALLOWED_MODULES = {
  30. RoleCode.USER: [AllowedModule.DOCUMENT_BROWSER.value],
  31. RoleCode.ADMIN: [
  32. AllowedModule.DOCUMENT_BROWSER.value,
  33. AllowedModule.BACKEND_MANAGEMENT.value,
  34. AllowedModule.AUDIT_LOG.value,
  35. AllowedModule.ORGANIZATION_USER_MANAGEMENT.value,
  36. ],
  37. }
  38. CREATE_FIELDS = {
  39. "username",
  40. "realName",
  41. "initialPassword",
  42. "organizationId",
  43. "roleCode",
  44. "roleId",
  45. "securityLevel",
  46. "status",
  47. }
  48. UPDATE_FIELDS = {
  49. "realName",
  50. "organizationId",
  51. "roleCode",
  52. "roleId",
  53. "securityLevel",
  54. "status",
  55. "rowVersion",
  56. }
  57. RESET_PASSWORD_FIELDS = {"newPassword", "rowVersion"}
  58. def _utc_now() -> datetime:
  59. return datetime.now(timezone.utc).replace(tzinfo=None)
  60. def effective_organization(user: User) -> tuple[int | None, str | None]:
  61. if user.organization_id is not None:
  62. organization = db.session.scalar(
  63. select(Organization).where(
  64. Organization.id == user.organization_id,
  65. Organization.is_deleted.is_(False),
  66. Organization.status == EnabledStatus.ENABLED.value,
  67. )
  68. )
  69. if organization is not None:
  70. return organization.id, organization.org_name
  71. return user.organization_id, user.organization_name
  72. def _format_utc(value: datetime | None) -> str | None:
  73. if value is None:
  74. return None
  75. return (
  76. value.replace(tzinfo=UTC)
  77. .isoformat(timespec="milliseconds")
  78. .replace("+00:00", "Z")
  79. )
  80. def allowed_modules_for_user(user: User) -> list[str]:
  81. """按“用户对文档的实际权限”计算可见模块(动态菜单口径)。
  82. - 浏览:登录即有;
  83. - 后台管理:管理员恒可见;普通用户在角色矩阵对任一已发布主案命中
  84. 管理 位(编辑/删除/授权/挂载/解除挂载)时可见;
  85. - 审计 / 机构用户管理:仅管理员。
  86. """
  87. if RoleCode(user.role_code) == RoleCode.ADMIN:
  88. return list(ALLOWED_MODULES[RoleCode.ADMIN])
  89. modules = [AllowedModule.DOCUMENT_BROWSER.value]
  90. from dms.security.auth_context import AuthContext
  91. from dms.services.authorization_service import has_any_manage_action
  92. if has_any_manage_action(
  93. AuthContext(
  94. user_id=user.id,
  95. username=user.username,
  96. real_name=user.real_name,
  97. organization_id=user.organization_id,
  98. organization_name=user.organization_name,
  99. role_code=RoleCode(user.role_code),
  100. security_level=SecurityLevel(user.security_level),
  101. auth_version=user.auth_version,
  102. )
  103. ):
  104. modules.append(AllowedModule.BACKEND_MANAGEMENT.value)
  105. return modules
  106. def effective_role(user: User) -> tuple[str | None, str | None]:
  107. if user.role_id is None:
  108. return None, None
  109. role = db.session.get(Role, user.role_id)
  110. if role is None or role.is_deleted:
  111. return serialize_id(user.role_id), None
  112. return serialize_id(role.id), role.role_name
  113. def user_summary(
  114. user: User,
  115. *,
  116. allowed_modules: list[str] | None = None,
  117. ) -> dict[str, object]:
  118. organization_id, organization_name = effective_organization(user)
  119. role_id, role_name = effective_role(user)
  120. return {
  121. "id": serialize_id(user.id),
  122. "username": user.username,
  123. "realName": user.real_name,
  124. "organizationId": serialize_id(organization_id),
  125. "organizationName": organization_name,
  126. "roleCode": user.role_code,
  127. "roleId": role_id,
  128. "roleName": role_name,
  129. "securityLevel": user.security_level,
  130. "status": user.status,
  131. "authVersion": user.auth_version,
  132. "rowVersion": user.row_version,
  133. "lastLoginAt": _format_utc(user.last_login_at),
  134. "lastLoginIp": user.last_login_ip,
  135. "allowedModules": (
  136. allowed_modules
  137. if allowed_modules is not None
  138. else ALLOWED_MODULES[RoleCode(user.role_code)]
  139. ),
  140. }
  141. def parse_string_id(raw_value: str | None, *, name: str) -> int | None:
  142. if raw_value is None or raw_value == "":
  143. return None
  144. if not raw_value.isdecimal() or int(raw_value) <= 0:
  145. raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
  146. return int(raw_value)
  147. def _descendant_ids(root_id: int) -> set[int]:
  148. organizations = db.session.scalars(
  149. select(Organization).where(Organization.is_deleted.is_(False))
  150. ).all()
  151. if not any(item.id == root_id for item in organizations):
  152. raise ResourceNotFoundError("组织不存在")
  153. children: dict[int | None, list[int]] = {}
  154. for item in organizations:
  155. if item.status == EnabledStatus.ENABLED.value:
  156. children.setdefault(item.parent_id, []).append(item.id)
  157. found = {root_id}
  158. pending = [root_id]
  159. while pending:
  160. current = pending.pop()
  161. for child_id in children.get(current, []):
  162. if child_id not in found:
  163. found.add(child_id)
  164. pending.append(child_id)
  165. return found
  166. def list_users(
  167. *,
  168. organization_id: int | None,
  169. include_descendants: bool,
  170. keyword: str | None,
  171. status: str | None,
  172. page_request: PageRequest,
  173. ) -> dict[str, object]:
  174. filters = [User.is_deleted.is_(False)]
  175. if organization_id is not None:
  176. organization_ids = (
  177. _descendant_ids(organization_id)
  178. if include_descendants
  179. else {organization_id}
  180. )
  181. filters.append(User.organization_id.in_(organization_ids))
  182. if keyword:
  183. pattern = f"%{keyword.strip()}%"
  184. filters.append(or_(User.username.like(pattern), User.real_name.like(pattern)))
  185. if status:
  186. try:
  187. normalized_status = EnabledStatus(status).value
  188. except ValueError as exc:
  189. raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
  190. filters.append(User.status == normalized_status)
  191. total = db.session.scalar(select(func.count(User.id)).where(*filters)) or 0
  192. users = db.session.scalars(
  193. select(User)
  194. .where(*filters)
  195. .order_by(User.real_name.asc(), User.id.asc())
  196. .offset(page_request.offset)
  197. .limit(page_request.page_size)
  198. ).all()
  199. return page_result(
  200. [user_summary(user) for user in users],
  201. page=page_request.page,
  202. page_size=page_request.page_size,
  203. total=total,
  204. )
  205. # ===== 用户维护写操作 =====
  206. def _required_text(
  207. value: Any,
  208. *,
  209. field: str,
  210. max_length: int,
  211. ) -> str:
  212. if not isinstance(value, str) or not value.strip():
  213. raise InvalidArgumentError(f"{field}不能为空")
  214. normalized = value.strip()
  215. if len(normalized) > max_length:
  216. raise InvalidArgumentError(f"{field}长度不能超过{max_length}")
  217. return normalized
  218. def _required_password(value: Any) -> str:
  219. if not isinstance(value, str) or not value:
  220. raise InvalidArgumentError("密码不能为空")
  221. if len(value) < 8 or len(value) > 64:
  222. raise InvalidArgumentError("密码长度必须在8~64个字符之间")
  223. return value
  224. def _role_code(value: Any) -> str:
  225. if not isinstance(value, str):
  226. raise InvalidArgumentError("roleCode必须是字符串")
  227. try:
  228. return RoleCode(value).value
  229. except ValueError as exc:
  230. raise InvalidArgumentError("roleCode不是有效枚举值") from exc
  231. def _security_level(value: Any) -> str:
  232. if not isinstance(value, str):
  233. raise InvalidArgumentError("securityLevel必须是字符串")
  234. try:
  235. return SecurityLevel(value).value
  236. except ValueError as exc:
  237. raise InvalidArgumentError("securityLevel不是有效枚举值") from exc
  238. def _status_value(value: Any) -> str:
  239. if not isinstance(value, str):
  240. raise InvalidArgumentError("status必须是字符串")
  241. try:
  242. return EnabledStatus(value).value
  243. except ValueError as exc:
  244. raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
  245. def _row_version(value: Any) -> int:
  246. if type(value) is not int or value < 0:
  247. raise InvalidArgumentError("rowVersion必须是非负整数")
  248. return value
  249. def _exact_payload(payload: Any, expected: set[str]) -> dict[str, Any]:
  250. if not isinstance(payload, dict) or set(payload) != expected:
  251. raise InvalidArgumentError(
  252. "请求体字段必须且只能包含:" + "、".join(sorted(expected))
  253. )
  254. return payload
  255. def _resolve_organization(
  256. session, organization_id: int | None
  257. ) -> tuple[int | None, str | None]:
  258. if organization_id is None:
  259. return None, None
  260. organization = session.scalar(
  261. select(Organization)
  262. .where(
  263. Organization.id == organization_id,
  264. Organization.is_deleted.is_(False),
  265. Organization.status == EnabledStatus.ENABLED.value,
  266. )
  267. .with_for_update()
  268. )
  269. if organization is None:
  270. raise InvalidArgumentError("organizationId必须指向有效且已启用的机构")
  271. return organization.id, organization.org_name
  272. def _resolve_role(
  273. session, role_id: int | None, role_code: str
  274. ) -> tuple[int | None, str | None]:
  275. """校验并解析自定义角色;ADMIN 恒为超管,不挂自定义角色。"""
  276. if role_id is None:
  277. return None, None
  278. if role_code == RoleCode.ADMIN.value:
  279. raise InvalidArgumentError("ADMIN账号不能关联自定义角色,roleId必须为空")
  280. role = session.scalar(
  281. select(Role).where(Role.id == role_id, Role.is_deleted.is_(False))
  282. )
  283. if role is None:
  284. raise InvalidArgumentError("roleId必须指向有效角色")
  285. return role.id, role.role_name
  286. def _ensure_not_self(actor: Any, target_user_id: int, *, action: str) -> None:
  287. if actor.user_id == target_user_id:
  288. raise UserSelfProtectionError(
  289. f"不能对本账号执行{action}操作",
  290. )
  291. def _other_active_admin_count(session, exclude_user_id: int) -> int:
  292. """统计除指定用户外的活跃管理员数量。"""
  293. return (
  294. session.scalar(
  295. select(func.count(User.id)).where(
  296. User.role_code == RoleCode.ADMIN.value,
  297. User.status == EnabledStatus.ENABLED.value,
  298. User.is_deleted.is_(False),
  299. User.id != exclude_user_id,
  300. )
  301. )
  302. or 0
  303. )
  304. def _ensure_not_last_active_admin(session, user: User, *, reason: str) -> None:
  305. """禁止影响系统最后一个活跃管理员(仅在 user 当前是活跃管理员时生效)。
  306. 统一的守卫:覆盖删除、降级、停用三种场景。
  307. 若 user 当前不是活跃管理员(角色非 ADMIN 或状态非 ENABLED),
  308. 则其消失不影响活跃管理员数,直接放行。
  309. """
  310. if (
  311. user.role_code != RoleCode.ADMIN.value
  312. or user.status != EnabledStatus.ENABLED.value
  313. ):
  314. return
  315. if _other_active_admin_count(session, user.id) == 0:
  316. raise ConflictError(
  317. "系统至少需保留一个启用的系统管理员",
  318. details={"roleId": user.role_code, "reason": reason},
  319. )
  320. def create_user(payload: Any) -> dict[str, object]:
  321. body = _exact_payload(payload, CREATE_FIELDS)
  322. username = _required_text(body["username"], field="username", max_length=64)
  323. real_name = _required_text(body["realName"], field="realName", max_length=64)
  324. initial_password = _required_password(body["initialPassword"])
  325. organization_id = parse_string_id(body["organizationId"], name="organizationId")
  326. role_code = _role_code(body["roleCode"])
  327. role_id = parse_string_id(body["roleId"], name="roleId")
  328. security_level = _security_level(body["securityLevel"])
  329. status = _status_value(body["status"])
  330. actor_id = get_auth_context().user_id
  331. try:
  332. with transaction() as session:
  333. if (
  334. session.scalar(
  335. select(User.id).where(
  336. User.username == username,
  337. User.is_deleted.is_(False),
  338. )
  339. )
  340. is not None
  341. ):
  342. raise ConflictError("用户名已存在")
  343. org_id, org_name = _resolve_organization(session, organization_id)
  344. resolved_role_id, resolved_role_name = _resolve_role(
  345. session, role_id, role_code
  346. )
  347. user = User(
  348. username=username,
  349. password_hash=hash_password(initial_password),
  350. real_name=real_name,
  351. organization_id=org_id,
  352. organization_name=org_name,
  353. role_code=role_code,
  354. role_id=resolved_role_id,
  355. security_level=security_level,
  356. status=status,
  357. created_by=actor_id,
  358. updated_by=actor_id,
  359. )
  360. session.add(user)
  361. session.flush()
  362. session.add(
  363. business_audit(
  364. action=AuditAction.CREATE_USER,
  365. target=AuditTarget.USER,
  366. target_id=user.id,
  367. target_name=user.real_name,
  368. detail={
  369. "username": user.username,
  370. "organizationId": serialize_id(user.organization_id),
  371. "organizationName": user.organization_name,
  372. "roleCode": user.role_code,
  373. "roleId": serialize_id(user.role_id),
  374. "roleName": resolved_role_name,
  375. "securityLevel": user.security_level,
  376. "status": user.status,
  377. },
  378. )
  379. )
  380. return user_summary(user)
  381. except IntegrityError as exc:
  382. raise ConflictError("用户名已存在") from exc
  383. def update_user(user_id: int, payload: Any) -> dict[str, object]:
  384. body = _exact_payload(payload, UPDATE_FIELDS)
  385. real_name = _required_text(body["realName"], field="realName", max_length=64)
  386. organization_id = parse_string_id(
  387. body["organizationId"], name="organizationId"
  388. )
  389. new_role_code = _role_code(body["roleCode"])
  390. new_role_id = parse_string_id(body["roleId"], name="roleId")
  391. new_security_level = _security_level(body["securityLevel"])
  392. new_status = _status_value(body["status"])
  393. expected_version = _row_version(body["rowVersion"])
  394. actor = get_auth_context()
  395. actor_id = actor.user_id
  396. now = _utc_now()
  397. _ensure_not_self(actor, user_id, action="编辑角色或状态")
  398. with transaction() as session:
  399. user = session.scalar(
  400. select(User)
  401. .where(
  402. User.id == user_id,
  403. User.is_deleted.is_(False),
  404. )
  405. .with_for_update()
  406. )
  407. if user is None:
  408. raise ResourceNotFoundError("用户不存在")
  409. if user.row_version != expected_version:
  410. raise ConflictError(
  411. "数据已被其他用户修改,请刷新后重试",
  412. details={"currentRowVersion": user.row_version},
  413. )
  414. before = {
  415. "realName": user.real_name,
  416. "organizationId": serialize_id(user.organization_id),
  417. "organizationName": user.organization_name,
  418. "roleCode": user.role_code,
  419. "roleId": serialize_id(user.role_id),
  420. "securityLevel": user.security_level,
  421. "status": user.status,
  422. "rowVersion": user.row_version,
  423. }
  424. # 角色变更或停用:合并检查最后一个活跃管理员
  425. role_changes = new_role_code != user.role_code
  426. status_changes = new_status != user.status
  427. will_lose_active_admin = (
  428. role_changes
  429. or (status_changes and new_status == EnabledStatus.DISABLED.value)
  430. )
  431. if will_lose_active_admin:
  432. _ensure_not_last_active_admin(
  433. session, user, reason="role_or_status_change"
  434. )
  435. org_id, org_name = _resolve_organization(session, organization_id)
  436. resolved_role_id, resolved_role_name = _resolve_role(
  437. session, new_role_id, new_role_code
  438. )
  439. role_assignment_changes = resolved_role_id != user.role_id
  440. user.real_name = real_name
  441. user.organization_id = org_id
  442. user.organization_name = org_name
  443. user.role_code = new_role_code
  444. user.role_id = resolved_role_id
  445. user.security_level = new_security_level
  446. user.status = new_status
  447. user.updated_by = actor_id
  448. user.updated_at = now
  449. user.row_version += 1
  450. # 内置角色、自定义角色变更或停用都需让旧 Token 失效
  451. auth_bump = (
  452. role_changes
  453. or role_assignment_changes
  454. or (status_changes and new_status == EnabledStatus.DISABLED.value)
  455. )
  456. if auth_bump:
  457. user.auth_version += 1
  458. after = {
  459. "realName": user.real_name,
  460. "organizationId": serialize_id(user.organization_id),
  461. "organizationName": user.organization_name,
  462. "roleCode": user.role_code,
  463. "roleId": serialize_id(user.role_id),
  464. "securityLevel": user.security_level,
  465. "status": user.status,
  466. "rowVersion": user.row_version,
  467. }
  468. session.add(
  469. business_audit(
  470. action=AuditAction.EDIT_USER,
  471. target=AuditTarget.USER,
  472. target_id=user.id,
  473. target_name=user.real_name,
  474. detail={
  475. "before": before,
  476. "after": after,
  477. "roleName": resolved_role_name,
  478. "authVersionBumped": auth_bump,
  479. },
  480. )
  481. )
  482. return user_summary(user)
  483. def delete_user(user_id: int, row_version: int) -> dict[str, object]:
  484. actor = get_auth_context()
  485. actor_id = actor.user_id
  486. now = _utc_now()
  487. _ensure_not_self(actor, user_id, action="删除")
  488. with transaction() as session:
  489. user = session.scalar(
  490. select(User)
  491. .where(
  492. User.id == user_id,
  493. User.is_deleted.is_(False),
  494. )
  495. .with_for_update()
  496. )
  497. if user is None:
  498. raise ResourceNotFoundError("用户不存在")
  499. if user.row_version != row_version:
  500. raise ConflictError(
  501. "数据已被其他用户修改,请刷新后重试",
  502. details={"currentRowVersion": user.row_version},
  503. )
  504. # 删除:禁止删除最后一个活跃管理员
  505. _ensure_not_last_active_admin(session, user, reason="delete")
  506. target_name = user.real_name
  507. target_username = user.username
  508. user.is_deleted = True
  509. user.deleted_at = now
  510. user.updated_at = now
  511. user.updated_by = actor_id
  512. user.status = EnabledStatus.DISABLED.value
  513. user.auth_version += 1
  514. user.row_version += 1
  515. session.add(
  516. business_audit(
  517. action=AuditAction.DELETE_USER,
  518. target=AuditTarget.USER,
  519. target_id=user.id,
  520. target_name=target_name,
  521. detail={
  522. "username": target_username,
  523. "rowVersion": user.row_version,
  524. },
  525. )
  526. )
  527. return {"id": serialize_id(user.id), "deleted": True}
  528. def reset_user_password(user_id: int, payload: Any) -> dict[str, object]:
  529. body = _exact_payload(payload, RESET_PASSWORD_FIELDS)
  530. new_password = _required_password(body["newPassword"])
  531. expected_version = _row_version(body["rowVersion"])
  532. actor = get_auth_context()
  533. actor_id = actor.user_id
  534. now = _utc_now()
  535. _ensure_not_self(actor, user_id, action="重置密码")
  536. with transaction() as session:
  537. user = session.scalar(
  538. select(User)
  539. .where(
  540. User.id == user_id,
  541. User.is_deleted.is_(False),
  542. )
  543. .with_for_update()
  544. )
  545. if user is None:
  546. raise ResourceNotFoundError("用户不存在")
  547. if user.row_version != expected_version:
  548. raise ConflictError(
  549. "数据已被其他用户修改,请刷新后重试",
  550. details={"currentRowVersion": user.row_version},
  551. )
  552. user.password_hash = hash_password(new_password)
  553. user.auth_version += 1
  554. user.updated_by = actor_id
  555. user.updated_at = now
  556. user.row_version += 1
  557. session.add(
  558. business_audit(
  559. action=AuditAction.RESET_USER_PASSWORD,
  560. target=AuditTarget.USER,
  561. target_id=user.id,
  562. target_name=user.real_name,
  563. detail={
  564. "username": user.username,
  565. "rowVersion": user.row_version,
  566. },
  567. )
  568. )
  569. return {
  570. "id": serialize_id(user.id),
  571. "reset": True,
  572. "authVersion": user.auth_version,
  573. }