user_service.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994
  1. """用户摘要、组织解析、列表查询与用户维护写操作。"""
  2. from __future__ import annotations
  3. from datetime import UTC, datetime, timezone
  4. from typing import Any
  5. from flask import current_app
  6. from sqlalchemy import func, or_, select
  7. from sqlalchemy.exc import IntegrityError
  8. from dms.common.enums import (
  9. AllowedModule,
  10. AuditAction,
  11. AuditTarget,
  12. EnabledStatus,
  13. RoleCode,
  14. SecurityLevel,
  15. )
  16. from dms.common.errors import (
  17. AuthenticationError,
  18. ConflictError,
  19. ForbiddenError,
  20. InvalidArgumentError,
  21. ResourceNotFoundError,
  22. UserSelfProtectionError,
  23. )
  24. from dms.common.pagination import PageRequest, page_result
  25. from dms.common.response import serialize_id
  26. from dms.database.transaction import transaction
  27. from dms.extensions import db
  28. from dms.models import Organization, Role, User
  29. from dms.security.auth_context import get_auth_context
  30. from dms.security.decorators import require_reauth
  31. from dms.security.passwords import hash_password, validate_password_policy
  32. from dms.services.admin_permission import (
  33. MANAGED_ORG_SELF,
  34. MANAGED_ORG_SELF_ANCHOR,
  35. SUPER_ADMIN_USERNAME,
  36. can_edit_categories,
  37. can_edit_organizations,
  38. can_edit_roles,
  39. can_edit_users,
  40. can_manage_system,
  41. can_view_audit_log,
  42. effective_manage_role,
  43. effective_role_snapshot,
  44. ensure_org_in_managed_scope,
  45. ensure_user_scope_in_managed_scope,
  46. is_super_admin,
  47. managed_scope_ids,
  48. require_user_editor,
  49. )
  50. from dms.services.audit_service import business_audit
  51. from dms.services.authorization_service import (
  52. PLAN_MANAGE_MATRIX_COLUMNS,
  53. VIEW_MATRIX_COLUMNS,
  54. has_any_matrix_permission,
  55. has_any_upload_permission,
  56. )
  57. CREATE_FIELDS = {
  58. "username",
  59. "realName",
  60. "initialPassword",
  61. "organizationId",
  62. "roleCode",
  63. "roleId",
  64. "securityLevel",
  65. "status",
  66. }
  67. UPDATE_FIELDS = {
  68. "realName",
  69. "organizationId",
  70. "roleCode",
  71. "roleId",
  72. "securityLevel",
  73. "status",
  74. "rowVersion",
  75. }
  76. RESET_PASSWORD_FIELDS = {"newPassword", "rowVersion"}
  77. # 用户级管理范围(契约1.30)为可选字段:create 未提交缺省落 SELF(哨兵0),
  78. # update 未提交保持原值,兼容不带该字段的存量调用方
  79. OPTIONAL_MANAGED_ORG_FIELDS = {"managedOrgId"}
  80. def _utc_now() -> datetime:
  81. return datetime.now(timezone.utc).replace(tzinfo=None)
  82. def effective_organization(user: User) -> tuple[int | None, str | None]:
  83. if user.organization_id is not None:
  84. organization = db.session.scalar(
  85. select(Organization).where(
  86. Organization.id == user.organization_id,
  87. Organization.is_deleted.is_(False),
  88. Organization.status == EnabledStatus.ENABLED.value,
  89. )
  90. )
  91. if organization is not None:
  92. return organization.id, organization.org_name
  93. return user.organization_id, user.organization_name
  94. def _format_utc(value: datetime | None) -> str | None:
  95. if value is None:
  96. return None
  97. return (
  98. value.replace(tzinfo=UTC)
  99. .isoformat(timespec="milliseconds")
  100. .replace("+00:00", "Z")
  101. )
  102. def allowed_modules_for_user(user: User) -> list[str]:
  103. """按角色权限推导可见模块(契约1.35菜单口径,与用户类别无关)。
  104. 菜单可见性完全由生效角色权限决定(纯矩阵/管理位判定,不查文档
  105. 存在性);页签内按钮与接口鉴权仍按 evaluate_plan_access 实时判定:
  106. - 方案权限矩阵含管理类列(上传/编辑/删除/授权/挂载)任一,
  107. 或具备分类管理位:渲染「方案管理」,不再渲染「方案浏览」;
  108. - 否则矩阵含浏览类列(查看/下载)任一:渲染「方案浏览」;
  109. - 日志审计/系统管理/机构用户管理位(机构/用户/角色任一):
  110. 渲染对应页签;
  111. - 矩阵与管理位全空:兜底渲染「方案浏览」;
  112. - 内置 admin 恒全量四页签(无方案浏览)。
  113. """
  114. if is_super_admin(user):
  115. return [
  116. AllowedModule.BACKEND_MANAGEMENT.value,
  117. AllowedModule.AUDIT_LOG.value,
  118. AllowedModule.ORGANIZATION_USER_MANAGEMENT.value,
  119. AllowedModule.SYSTEM_MANAGEMENT.value,
  120. ]
  121. from dms.security.auth_context import AuthContext
  122. context = AuthContext(
  123. user_id=user.id,
  124. username=user.username,
  125. real_name=user.real_name,
  126. organization_id=user.organization_id,
  127. organization_name=user.organization_name,
  128. role_code=RoleCode(user.role_code),
  129. security_level=SecurityLevel(user.security_level),
  130. auth_version=user.auth_version,
  131. )
  132. modules: list[str] = []
  133. if has_any_matrix_permission(
  134. context, PLAN_MANAGE_MATRIX_COLUMNS
  135. ) or can_edit_categories(db.session, user):
  136. modules.append(AllowedModule.BACKEND_MANAGEMENT.value)
  137. elif has_any_matrix_permission(context, VIEW_MATRIX_COLUMNS):
  138. modules.append(AllowedModule.DOCUMENT_BROWSER.value)
  139. if can_view_audit_log(db.session, user):
  140. modules.append(AllowedModule.AUDIT_LOG.value)
  141. if (
  142. can_edit_organizations(db.session, user)
  143. or can_edit_users(db.session, user)
  144. or can_edit_roles(db.session, user)
  145. ):
  146. modules.append(AllowedModule.ORGANIZATION_USER_MANAGEMENT.value)
  147. if can_manage_system(db.session, user):
  148. modules.append(AllowedModule.SYSTEM_MANAGEMENT.value)
  149. if not modules:
  150. modules.append(AllowedModule.DOCUMENT_BROWSER.value)
  151. return modules
  152. def effective_role(user: User) -> tuple[str | None, str | None]:
  153. if user.role_id is None:
  154. return None, None
  155. role = db.session.get(Role, user.role_id)
  156. if role is None or role.is_deleted:
  157. return serialize_id(user.role_id), None
  158. return serialize_id(role.id), role.role_name
  159. def user_summary(
  160. user: User,
  161. *,
  162. allowed_modules: list[str] | None = None,
  163. ) -> dict[str, object]:
  164. organization_id, organization_name = effective_organization(user)
  165. role_id, role_name = effective_role(user)
  166. manage_role = effective_manage_role(db.session, user)
  167. # 内置 admin 恒为全权超管(契约1.17③):perm 位恒开、锚点恒全局
  168. super_admin = is_super_admin(user)
  169. return {
  170. "id": serialize_id(user.id),
  171. "username": user.username,
  172. "realName": user.real_name,
  173. "organizationId": serialize_id(organization_id),
  174. "organizationName": organization_name,
  175. "roleCode": user.role_code,
  176. "roleId": role_id,
  177. "roleName": role_name,
  178. "securityLevel": user.security_level,
  179. "status": user.status,
  180. "authVersion": user.auth_version,
  181. "rowVersion": user.row_version,
  182. # 管理权限改挂角色:字段名保留,值取自生效角色
  183. "permCategoryEdit": (
  184. True
  185. if super_admin
  186. else (
  187. bool(manage_role.manage_category_edit)
  188. if manage_role is not None
  189. else False
  190. )
  191. ),
  192. "permOrgEdit": (
  193. True
  194. if super_admin
  195. else (
  196. bool(manage_role.manage_org_edit) if manage_role is not None else False
  197. )
  198. ),
  199. "permUserEdit": (
  200. True
  201. if super_admin
  202. else (
  203. bool(manage_role.manage_user_edit) if manage_role is not None else False
  204. )
  205. ),
  206. "permSystemManage": (
  207. True
  208. if super_admin
  209. else (
  210. bool(manage_role.manage_system) if manage_role is not None else False
  211. )
  212. ),
  213. "permAuditLog": (
  214. True
  215. if super_admin
  216. else (
  217. bool(manage_role.manage_audit_log)
  218. if manage_role is not None
  219. else False
  220. )
  221. ),
  222. "permRoleEdit": (
  223. True
  224. if super_admin
  225. else (
  226. bool(manage_role.manage_role_edit)
  227. if manage_role is not None
  228. else False
  229. )
  230. ),
  231. # 上传权限(契约1.31):生效角色方案权限矩阵任一 OWN 行 can_upload=True
  232. "permUpload": (
  233. True
  234. if super_admin
  235. else has_any_upload_permission(
  236. manage_role.id if manage_role is not None else None
  237. )
  238. ),
  239. # 用户级管理范围(契约1.30):null=全局、"SELF"=跟随本人所在机构(哨兵0)、
  240. # 机构ID=固定锚点;实际生效范围以角色锚点为准,角色为 SELF 时才回落本设置。
  241. # 内置 admin 恒全局。
  242. "managedOrgId": (
  243. None if super_admin else _managed_org_value(user.managed_org_id)
  244. ),
  245. "lastLoginAt": _format_utc(user.last_login_at),
  246. "lastLoginIp": user.last_login_ip,
  247. "uiTheme": user.ui_theme,
  248. "allowedModules": (
  249. allowed_modules
  250. if allowed_modules is not None
  251. else allowed_modules_for_user(user)
  252. ),
  253. }
  254. def parse_string_id(raw_value: str | None, *, name: str) -> int | None:
  255. if raw_value is None or raw_value == "":
  256. return None
  257. if not raw_value.isdecimal() or int(raw_value) <= 0:
  258. raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
  259. return int(raw_value)
  260. def _descendant_ids(root_id: int) -> set[int]:
  261. organizations = db.session.scalars(
  262. select(Organization).where(Organization.is_deleted.is_(False))
  263. ).all()
  264. if not any(item.id == root_id for item in organizations):
  265. raise ResourceNotFoundError("组织不存在")
  266. children: dict[int | None, list[int]] = {}
  267. for item in organizations:
  268. if item.status == EnabledStatus.ENABLED.value:
  269. children.setdefault(item.parent_id, []).append(item.id)
  270. found = {root_id}
  271. pending = [root_id]
  272. while pending:
  273. current = pending.pop()
  274. for child_id in children.get(current, []):
  275. if child_id not in found:
  276. found.add(child_id)
  277. pending.append(child_id)
  278. return found
  279. def list_users(
  280. *,
  281. organization_id: int | None,
  282. include_descendants: bool,
  283. keyword: str | None,
  284. status: str | None,
  285. page_request: PageRequest,
  286. ) -> dict[str, object]:
  287. filters = [User.is_deleted.is_(False)]
  288. # 超级管理员登录开关(安全需求7)关闭时用户列表隐藏内置 admin
  289. if not current_app.config.get("DMS_ALLOW_ADMIN_LOGIN", True):
  290. filters.append(User.username != SUPER_ADMIN_USERNAME)
  291. # 锚点管理员强制收敛到管理范围子树(全局管理员不受限),
  292. # 与请求的 organization_id 过滤取交集。
  293. actor = db.session.get(User, get_auth_context().user_id)
  294. scope = managed_scope_ids(db.session, actor) if actor is not None else None
  295. if scope is not None:
  296. filters.append(User.organization_id.in_(scope))
  297. if organization_id is not None:
  298. organization_ids = (
  299. _descendant_ids(organization_id)
  300. if include_descendants
  301. else {organization_id}
  302. )
  303. # 显式请求的机构不在管理范围内:明确报无权限,
  304. # 不再静默交集返回空页(避免前端误显示"该机构无用户")
  305. if scope is not None and not (scope & organization_ids):
  306. raise ForbiddenError("无权限查看该机构的用户")
  307. filters.append(User.organization_id.in_(organization_ids))
  308. if keyword:
  309. pattern = f"%{keyword.strip()}%"
  310. filters.append(or_(User.username.like(pattern), User.real_name.like(pattern)))
  311. if status:
  312. try:
  313. normalized_status = EnabledStatus(status).value
  314. except ValueError as exc:
  315. raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
  316. filters.append(User.status == normalized_status)
  317. total = db.session.scalar(select(func.count(User.id)).where(*filters)) or 0
  318. users = db.session.scalars(
  319. select(User)
  320. .where(*filters)
  321. .order_by(User.sort_no.asc(), User.id.asc())
  322. .offset(page_request.offset)
  323. .limit(page_request.page_size)
  324. ).all()
  325. return page_result(
  326. [user_summary(user) for user in users],
  327. page=page_request.page,
  328. page_size=page_request.page_size,
  329. total=total,
  330. )
  331. # ===== 用户维护写操作 =====
  332. def _required_text(
  333. value: Any,
  334. *,
  335. field: str,
  336. max_length: int,
  337. ) -> str:
  338. if not isinstance(value, str) or not value.strip():
  339. raise InvalidArgumentError(f"{field}不能为空")
  340. normalized = value.strip()
  341. if len(normalized) > max_length:
  342. raise InvalidArgumentError(f"{field}长度不能超过{max_length}")
  343. return normalized
  344. def _required_password(value: Any) -> str:
  345. if not isinstance(value, str) or not value:
  346. raise InvalidArgumentError("密码不能为空")
  347. try:
  348. validate_password_policy(value)
  349. except ValueError as exc:
  350. raise InvalidArgumentError(str(exc)) from exc
  351. return value
  352. def _role_code(value: Any) -> str:
  353. if not isinstance(value, str):
  354. raise InvalidArgumentError("roleCode必须是字符串")
  355. try:
  356. return RoleCode(value).value
  357. except ValueError as exc:
  358. raise InvalidArgumentError("roleCode不是有效枚举值") from exc
  359. def _security_level(value: Any) -> str:
  360. if not isinstance(value, str):
  361. raise InvalidArgumentError("securityLevel必须是字符串")
  362. try:
  363. return SecurityLevel(value).value
  364. except ValueError as exc:
  365. raise InvalidArgumentError("securityLevel不是有效枚举值") from exc
  366. def _status_value(value: Any) -> str:
  367. if not isinstance(value, str):
  368. raise InvalidArgumentError("status必须是字符串")
  369. try:
  370. return EnabledStatus(value).value
  371. except ValueError as exc:
  372. raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
  373. def _row_version(value: Any) -> int:
  374. if type(value) is not int or value < 0:
  375. raise InvalidArgumentError("rowVersion必须是非负整数")
  376. return value
  377. def _exact_payload(
  378. payload: Any,
  379. expected: set[str],
  380. *,
  381. optional: set[str] | None = None,
  382. ) -> dict[str, Any]:
  383. allowed = expected | (optional or set())
  384. if not isinstance(payload, dict) or not expected <= set(payload) <= allowed:
  385. raise InvalidArgumentError(
  386. "请求体字段必须且只能包含:" + "、".join(sorted(allowed))
  387. )
  388. return payload
  389. def _resolve_organization(
  390. session, organization_id: int | None
  391. ) -> tuple[int | None, str | None]:
  392. if organization_id is None:
  393. return None, None
  394. organization = session.scalar(
  395. select(Organization)
  396. .where(
  397. Organization.id == organization_id,
  398. Organization.is_deleted.is_(False),
  399. Organization.status == EnabledStatus.ENABLED.value,
  400. )
  401. .with_for_update()
  402. )
  403. if organization is None:
  404. raise InvalidArgumentError("organizationId必须指向有效且已启用的机构")
  405. return organization.id, organization.org_name
  406. def _resolve_role(session, role_id: int | None) -> tuple[int | None, str | None]:
  407. """校验并解析自定义角色;USER 与受限 ADMIN 均可关联角色。
  408. ADMIN 关联的角色仅约束其文档操作权限矩阵,管理类权限位不受影响;
  409. 未关联角色(含内置 admin)由授权层回退默认角色。
  410. """
  411. if role_id is None:
  412. return None, None
  413. role = session.scalar(
  414. select(Role).where(Role.id == role_id, Role.is_deleted.is_(False))
  415. )
  416. if role is None:
  417. raise InvalidArgumentError("roleId必须指向有效角色")
  418. return role.id, role.role_name
  419. def _optional_managed_org(session, value: Any) -> int | None:
  420. """解析用户级管理范围三态(契约1.30)。
  421. null/空串=全局、"SELF"=跟随本人所在机构(哨兵0)、机构ID=固定锚点;
  422. 锚点须指向未删除机构(与角色锚点同口径,不要求启用、不限操作者范围)。
  423. """
  424. if value is None or value == "":
  425. return None
  426. if value == MANAGED_ORG_SELF:
  427. return MANAGED_ORG_SELF_ANCHOR
  428. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  429. raise InvalidArgumentError("managedOrgId必须是SELF、机构ID或null")
  430. org_id = int(value)
  431. exists = session.scalar(
  432. select(Organization.id).where(
  433. Organization.id == org_id,
  434. Organization.is_deleted.is_(False),
  435. )
  436. )
  437. if exists is None:
  438. raise InvalidArgumentError("managedOrgId必须指向有效机构")
  439. return org_id
  440. def _managed_org_value(value: int | None) -> str | None:
  441. """用户级管理范围序列化:NULL→null、哨兵0→"SELF"、其余→机构ID字符串。"""
  442. if value is None:
  443. return None
  444. if value == MANAGED_ORG_SELF_ANCHOR:
  445. return MANAGED_ORG_SELF
  446. return serialize_id(value)
  447. def _ensure_not_self(actor: Any, target_user_id: int, *, action: str) -> None:
  448. if actor.user_id == target_user_id:
  449. raise UserSelfProtectionError(
  450. f"不能对本账号执行{action}操作",
  451. )
  452. def _ensure_not_builtin_admin(user: User, *, action: str) -> None:
  453. """内置 admin 账号不允许被任何用户编辑、删除或重置密码。"""
  454. if user.username == SUPER_ADMIN_USERNAME:
  455. raise ForbiddenError(f"内置admin账号不允许被{action}")
  456. def _other_active_admin_count(session, exclude_user_id: int) -> int:
  457. """统计除指定用户外的活跃管理员数量。"""
  458. return (
  459. session.scalar(
  460. select(func.count(User.id)).where(
  461. User.role_code == RoleCode.ADMIN.value,
  462. User.status == EnabledStatus.ENABLED.value,
  463. User.is_deleted.is_(False),
  464. User.id != exclude_user_id,
  465. )
  466. )
  467. or 0
  468. )
  469. def _ensure_not_last_active_admin(session, user: User, *, reason: str) -> None:
  470. """禁止影响系统最后一个活跃管理员(仅在 user 当前是活跃管理员时生效)。
  471. 统一的守卫:覆盖删除、降级、停用三种场景。
  472. 若 user 当前不是活跃管理员(角色非 ADMIN 或状态非 ENABLED),
  473. 则其消失不影响活跃管理员数,直接放行。
  474. """
  475. if (
  476. user.role_code != RoleCode.ADMIN.value
  477. or user.status != EnabledStatus.ENABLED.value
  478. ):
  479. return
  480. if _other_active_admin_count(session, user.id) == 0:
  481. raise ConflictError(
  482. "系统至少需保留一个启用的系统管理员",
  483. details={"roleId": user.role_code, "reason": reason},
  484. )
  485. def create_user(payload: Any) -> dict[str, object]:
  486. body = _exact_payload(
  487. payload, CREATE_FIELDS, optional=OPTIONAL_MANAGED_ORG_FIELDS
  488. )
  489. username = _required_text(body["username"], field="username", max_length=64)
  490. real_name = _required_text(body["realName"], field="realName", max_length=64)
  491. initial_password = _required_password(body["initialPassword"])
  492. organization_id = parse_string_id(body["organizationId"], name="organizationId")
  493. role_code = _role_code(body["roleCode"])
  494. role_id = parse_string_id(body["roleId"], name="roleId")
  495. security_level = _security_level(body["securityLevel"])
  496. status = _status_value(body["status"])
  497. actor_id = get_auth_context().user_id
  498. # 超级管理员登录开关(安全需求7)关闭时拒绝创建内置 admin 用户名:
  499. # 报错不提示"已存在",不泄露系统中存在该账号
  500. if (
  501. username == SUPER_ADMIN_USERNAME
  502. and not current_app.config.get("DMS_ALLOW_ADMIN_LOGIN", True)
  503. ):
  504. raise InvalidArgumentError("不能创建该名称的用户")
  505. try:
  506. with transaction() as session:
  507. actor = require_user_editor(session)
  508. ensure_org_in_managed_scope(session, actor, organization_id)
  509. if (
  510. session.scalar(
  511. select(User.id).where(
  512. User.username == username,
  513. User.is_deleted.is_(False),
  514. )
  515. )
  516. is not None
  517. ):
  518. raise ConflictError("用户名已存在")
  519. org_id, org_name = _resolve_organization(session, organization_id)
  520. resolved_role_id, resolved_role_name = _resolve_role(session, role_id)
  521. # 缺省落 SELF(哨兵0):显式赋值不依赖数据库缺省,
  522. # 保证未提交该字段的新建用户与迁移回填的存量行语义一致
  523. managed_org_id = (
  524. _optional_managed_org(session, body["managedOrgId"])
  525. if "managedOrgId" in body
  526. else MANAGED_ORG_SELF_ANCHOR
  527. )
  528. # 防提权(契约1.30修订):目标 ADMIN 的生效管理范围不得超出操作者
  529. ensure_user_scope_in_managed_scope(
  530. session,
  531. actor,
  532. role_code=role_code,
  533. effective_role=effective_role_snapshot(session, resolved_role_id),
  534. managed_org_id=managed_org_id,
  535. organization_id=org_id,
  536. )
  537. user = User(
  538. username=username,
  539. password_hash=hash_password(initial_password),
  540. real_name=real_name,
  541. organization_id=org_id,
  542. organization_name=org_name,
  543. role_code=role_code,
  544. role_id=resolved_role_id,
  545. security_level=security_level,
  546. status=status,
  547. managed_org_id=managed_org_id,
  548. created_by=actor_id,
  549. updated_by=actor_id,
  550. )
  551. session.add(user)
  552. session.flush()
  553. # 回读服务端缺省列(ui_theme/auth_version/row_version 等 server_default):
  554. # flush 不回填且 expire_on_commit=False,不 refresh 则响应字段为 null 违反契约
  555. session.refresh(user)
  556. session.add(
  557. business_audit(
  558. action=AuditAction.CREATE_USER,
  559. target=AuditTarget.USER,
  560. target_id=user.id,
  561. target_name=user.real_name,
  562. detail={
  563. "username": user.username,
  564. "organizationId": serialize_id(user.organization_id),
  565. "organizationName": user.organization_name,
  566. "roleCode": user.role_code,
  567. "roleId": serialize_id(user.role_id),
  568. "roleName": resolved_role_name,
  569. "securityLevel": user.security_level,
  570. "status": user.status,
  571. "managedOrgId": _managed_org_value(user.managed_org_id),
  572. },
  573. )
  574. )
  575. return user_summary(user)
  576. except IntegrityError as exc:
  577. raise ConflictError("用户名已存在") from exc
  578. def update_user(user_id: int, payload: Any) -> dict[str, object]:
  579. body = _exact_payload(
  580. payload, UPDATE_FIELDS, optional=OPTIONAL_MANAGED_ORG_FIELDS
  581. )
  582. real_name = _required_text(body["realName"], field="realName", max_length=64)
  583. organization_id = parse_string_id(
  584. body["organizationId"], name="organizationId"
  585. )
  586. new_role_code = _role_code(body["roleCode"])
  587. new_role_id = parse_string_id(body["roleId"], name="roleId")
  588. new_security_level = _security_level(body["securityLevel"])
  589. new_status = _status_value(body["status"])
  590. expected_version = _row_version(body["rowVersion"])
  591. actor = get_auth_context()
  592. actor_id = actor.user_id
  593. now = _utc_now()
  594. _ensure_not_self(actor, user_id, action="编辑角色或状态")
  595. with transaction() as session:
  596. actor_user = require_user_editor(session)
  597. user = session.scalar(
  598. select(User)
  599. .where(
  600. User.id == user_id,
  601. User.is_deleted.is_(False),
  602. )
  603. .with_for_update()
  604. )
  605. if user is None:
  606. raise ResourceNotFoundError("用户不存在")
  607. _ensure_not_builtin_admin(user, action="编辑")
  608. ensure_org_in_managed_scope(session, actor_user, user.organization_id)
  609. if user.row_version != expected_version:
  610. raise ConflictError(
  611. "数据已被其他用户修改,请刷新后重试",
  612. details={"currentRowVersion": user.row_version},
  613. )
  614. before = {
  615. "realName": user.real_name,
  616. "organizationId": serialize_id(user.organization_id),
  617. "organizationName": user.organization_name,
  618. "roleCode": user.role_code,
  619. "roleId": serialize_id(user.role_id),
  620. "securityLevel": user.security_level,
  621. "status": user.status,
  622. "managedOrgId": _managed_org_value(user.managed_org_id),
  623. "rowVersion": user.row_version,
  624. }
  625. # 角色变更或停用:合并检查最后一个活跃管理员
  626. role_changes = new_role_code != user.role_code
  627. status_changes = new_status != user.status
  628. security_level_changes = new_security_level != user.security_level
  629. will_lose_active_admin = (
  630. role_changes
  631. or (status_changes and new_status == EnabledStatus.DISABLED.value)
  632. )
  633. if will_lose_active_admin:
  634. _ensure_not_last_active_admin(
  635. session, user, reason="role_or_status_change"
  636. )
  637. org_id, org_name = _resolve_organization(session, organization_id)
  638. # 迁入机构同样须在管理范围内:锚点管理员不可将用户移出自己管理的子树
  639. ensure_org_in_managed_scope(session, actor_user, org_id)
  640. resolved_role_id, resolved_role_name = _resolve_role(session, new_role_id)
  641. role_assignment_changes = resolved_role_id != user.role_id
  642. # 可选字段(契约1.30):未提交保持原值;调整不改变认证身份,
  643. # 不触发二次认证、不失效旧 Token(与角色锚点同口径)
  644. new_managed_org_id = (
  645. _optional_managed_org(session, body["managedOrgId"])
  646. if "managedOrgId" in body
  647. else None
  648. )
  649. # 防提权(契约1.30修订):目标 ADMIN 最终生效管理范围不得超出操作者;
  650. # 未提交 managedOrgId 时按保持原值判定。校验置于 reauth 之前,
  651. # 避免换角色场景先抛 REAUTH_REQUIRED 掩盖越权拒绝。
  652. final_managed_org_id = (
  653. new_managed_org_id if "managedOrgId" in body else user.managed_org_id
  654. )
  655. ensure_user_scope_in_managed_scope(
  656. session,
  657. actor_user,
  658. role_code=new_role_code,
  659. effective_role=effective_role_snapshot(session, resolved_role_id),
  660. managed_org_id=final_managed_org_id,
  661. organization_id=org_id,
  662. )
  663. # 敏感字段(密级/角色/状态)实际变化:强制操作人二次认证(契约1.21)
  664. if (
  665. security_level_changes
  666. or role_changes
  667. or role_assignment_changes
  668. or status_changes
  669. ):
  670. require_reauth()
  671. user.real_name = real_name
  672. user.organization_id = org_id
  673. user.organization_name = org_name
  674. user.role_code = new_role_code
  675. user.role_id = resolved_role_id
  676. user.security_level = new_security_level
  677. user.status = new_status
  678. if "managedOrgId" in body:
  679. user.managed_org_id = new_managed_org_id
  680. user.updated_by = actor_id
  681. user.updated_at = now
  682. user.row_version += 1
  683. # 内置角色、自定义角色变更或停用都需让旧 Token 失效;
  684. # 管理类权限与锚点调整不改变认证身份,不失效旧 Token
  685. auth_bump = (
  686. role_changes
  687. or role_assignment_changes
  688. or (status_changes and new_status == EnabledStatus.DISABLED.value)
  689. )
  690. if auth_bump:
  691. user.auth_version += 1
  692. after = {
  693. "realName": user.real_name,
  694. "organizationId": serialize_id(user.organization_id),
  695. "organizationName": user.organization_name,
  696. "roleCode": user.role_code,
  697. "roleId": serialize_id(user.role_id),
  698. "securityLevel": user.security_level,
  699. "status": user.status,
  700. "managedOrgId": _managed_org_value(user.managed_org_id),
  701. "rowVersion": user.row_version,
  702. }
  703. session.add(
  704. business_audit(
  705. action=AuditAction.EDIT_USER,
  706. target=AuditTarget.USER,
  707. target_id=user.id,
  708. target_name=user.real_name,
  709. detail={
  710. "before": before,
  711. "after": after,
  712. "roleName": resolved_role_name,
  713. "authVersionBumped": auth_bump,
  714. },
  715. )
  716. )
  717. return user_summary(user)
  718. def delete_user(user_id: int, row_version: int) -> dict[str, object]:
  719. actor = get_auth_context()
  720. actor_id = actor.user_id
  721. now = _utc_now()
  722. _ensure_not_self(actor, user_id, action="删除")
  723. with transaction() as session:
  724. actor_user = require_user_editor(session)
  725. user = session.scalar(
  726. select(User)
  727. .where(
  728. User.id == user_id,
  729. User.is_deleted.is_(False),
  730. )
  731. .with_for_update()
  732. )
  733. if user is None:
  734. raise ResourceNotFoundError("用户不存在")
  735. _ensure_not_builtin_admin(user, action="删除")
  736. ensure_org_in_managed_scope(session, actor_user, user.organization_id)
  737. if user.row_version != row_version:
  738. raise ConflictError(
  739. "数据已被其他用户修改,请刷新后重试",
  740. details={"currentRowVersion": user.row_version},
  741. )
  742. # 删除:禁止删除最后一个活跃管理员
  743. _ensure_not_last_active_admin(session, user, reason="delete")
  744. target_name = user.real_name
  745. target_username = user.username
  746. user.is_deleted = True
  747. user.deleted_at = now
  748. user.updated_at = now
  749. user.updated_by = actor_id
  750. user.status = EnabledStatus.DISABLED.value
  751. user.auth_version += 1
  752. user.row_version += 1
  753. session.add(
  754. business_audit(
  755. action=AuditAction.DELETE_USER,
  756. target=AuditTarget.USER,
  757. target_id=user.id,
  758. target_name=target_name,
  759. detail={
  760. "username": target_username,
  761. "rowVersion": user.row_version,
  762. },
  763. )
  764. )
  765. return {"id": serialize_id(user.id), "deleted": True}
  766. def reset_user_password(user_id: int, payload: Any) -> dict[str, object]:
  767. body = _exact_payload(payload, RESET_PASSWORD_FIELDS)
  768. new_password = _required_password(body["newPassword"])
  769. expected_version = _row_version(body["rowVersion"])
  770. actor = get_auth_context()
  771. actor_id = actor.user_id
  772. now = _utc_now()
  773. _ensure_not_self(actor, user_id, action="重置密码")
  774. with transaction() as session:
  775. actor_user = require_user_editor(session)
  776. user = session.scalar(
  777. select(User)
  778. .where(
  779. User.id == user_id,
  780. User.is_deleted.is_(False),
  781. )
  782. .with_for_update()
  783. )
  784. if user is None:
  785. raise ResourceNotFoundError("用户不存在")
  786. _ensure_not_builtin_admin(user, action="重置密码")
  787. ensure_org_in_managed_scope(session, actor_user, user.organization_id)
  788. if user.row_version != expected_version:
  789. raise ConflictError(
  790. "数据已被其他用户修改,请刷新后重试",
  791. details={"currentRowVersion": user.row_version},
  792. )
  793. user.password_hash = hash_password(new_password)
  794. user.auth_version += 1
  795. user.updated_by = actor_id
  796. user.updated_at = now
  797. user.row_version += 1
  798. session.add(
  799. business_audit(
  800. action=AuditAction.RESET_USER_PASSWORD,
  801. target=AuditTarget.USER,
  802. target_id=user.id,
  803. target_name=user.real_name,
  804. detail={
  805. "username": user.username,
  806. "rowVersion": user.row_version,
  807. },
  808. )
  809. )
  810. return {
  811. "id": serialize_id(user.id),
  812. "reset": True,
  813. "authVersion": user.auth_version,
  814. }
  815. UI_THEMES = ("blue", "red", "green")
  816. REORDER_FIELDS = {"orderedIds"}
  817. def reorder_users(payload: Any) -> dict[str, object]:
  818. """用户列表拖动重排(契约1.27):全体用户槽位置换,步长10重编号 sort_no。
  819. orderedIds 允许为当前分页视图的子集,被提交用户按原槽位次序互换
  820. (未提交用户相对次序不变),与分类同级重排的置换语义一致。
  821. """
  822. body = _exact_payload(payload, REORDER_FIELDS)
  823. ordered_ids = body["orderedIds"]
  824. if not isinstance(ordered_ids, list) or any(
  825. not isinstance(item, str) or not item.isdecimal() or int(item) <= 0
  826. for item in ordered_ids
  827. ):
  828. raise InvalidArgumentError("orderedIds必须是正整数形式的字符串ID数组")
  829. if len(set(ordered_ids)) != len(ordered_ids):
  830. raise InvalidArgumentError("orderedIds存在重复ID")
  831. if len(ordered_ids) < 2:
  832. raise InvalidArgumentError("orderedIds必须是至少包含2个ID的数组")
  833. actor_id = get_auth_context().user_id
  834. now = _utc_now()
  835. with transaction() as session:
  836. require_user_editor(session)
  837. rows = session.scalars(
  838. select(User)
  839. .where(User.is_deleted.is_(False))
  840. .order_by(User.sort_no.asc(), User.id.asc())
  841. .with_for_update()
  842. ).all()
  843. row_by_id = {row.id: row for row in rows}
  844. all_ids = [row.id for row in rows]
  845. ordered = [int(item) for item in ordered_ids]
  846. if any(user_id not in row_by_id for user_id in ordered):
  847. raise ResourceNotFoundError("用户不存在")
  848. # 槽位置换:被提交ID按原槽位顺序排好,依次填入目标顺序
  849. slot_ids = sorted(ordered, key=all_ids.index)
  850. new_order = list(all_ids)
  851. for position, user_id in enumerate(slot_ids):
  852. new_order[all_ids.index(user_id)] = ordered[position]
  853. reordered_count = 0
  854. for index, user_id in enumerate(new_order):
  855. row = row_by_id[user_id]
  856. target = (index + 1) * 10
  857. if row.sort_no == target:
  858. continue
  859. row.sort_no = target
  860. row.updated_by = actor_id
  861. row.updated_at = now
  862. row.row_version += 1
  863. reordered_count += 1
  864. first = row_by_id[ordered[0]]
  865. session.add(
  866. business_audit(
  867. action=AuditAction.REORDER_USER,
  868. target=AuditTarget.USER,
  869. target_id=first.id,
  870. target_name=first.real_name,
  871. detail={
  872. "orderedIds": list(ordered_ids),
  873. "reorderedCount": reordered_count,
  874. },
  875. )
  876. )
  877. return {"reorderedCount": reordered_count}
  878. def update_ui_theme(payload: Any) -> dict[str, object]:
  879. """本人界面主题偏好(契约1.20):白名单校验,仅操作本人行。
  880. 主题是账号级漫游的纯视觉偏好:不 bump rowVersion(避免干扰并发
  881. 编辑乐观锁)、不 bump authVersion(不下线会话)、不写审计。
  882. """
  883. body = _exact_payload(payload, {"uiTheme"})
  884. theme = body["uiTheme"]
  885. if not isinstance(theme, str) or theme not in UI_THEMES:
  886. raise InvalidArgumentError("uiTheme必须是blue、red或green")
  887. context = get_auth_context()
  888. with transaction() as session:
  889. user = session.scalar(
  890. select(User).where(
  891. User.id == context.user_id,
  892. User.is_deleted.is_(False),
  893. )
  894. )
  895. if user is None:
  896. raise AuthenticationError()
  897. user.ui_theme = theme
  898. session.flush()
  899. return user_summary(user)