user_service.py 35 KB

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