user_service.py 20 KB

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