category_service.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  1. """方案分类树、写操作、路径冗余和占用规则。"""
  2. from __future__ import annotations
  3. from collections import deque
  4. from datetime import datetime, timezone
  5. from typing import Any
  6. from sqlalchemy import exists, func, select, update
  7. from sqlalchemy.exc import IntegrityError
  8. from dms.common.enums import (
  9. AuditAction,
  10. AuditTarget,
  11. CategoryType,
  12. DocumentType,
  13. EnabledStatus,
  14. )
  15. from dms.common.errors import (
  16. CategoryFlatModeConflictError,
  17. CategoryInUseError,
  18. CategoryNotLeafError,
  19. ConflictError,
  20. InvalidArgumentError,
  21. ResourceNotFoundError,
  22. )
  23. from dms.common.response import serialize_id
  24. from dms.database.transaction import execute_with_deadlock_retry, transaction
  25. from dms.extensions import db
  26. from dms.models import AttachmentBinding, Category, Document, SubPlanBinding
  27. from dms.security.auth_context import get_auth_context
  28. from dms.services.admin_permission import require_category_editor
  29. from dms.services.audit_service import business_audit
  30. CREATE_FIELDS = {
  31. "categoryCode",
  32. "categoryName",
  33. "categoryType",
  34. "parentId",
  35. "sortNo",
  36. "flatMode",
  37. "typeDisplayName",
  38. }
  39. UPDATE_FIELDS = {
  40. "categoryName",
  41. "categoryType",
  42. "parentId",
  43. "sortNo",
  44. "rowVersion",
  45. "flatMode",
  46. "typeDisplayName",
  47. }
  48. def _utc_now() -> datetime:
  49. return datetime.now(timezone.utc).replace(tzinfo=None)
  50. def _string_id(value: Any, *, field: str, nullable: bool = False) -> int | None:
  51. if value is None and nullable:
  52. return None
  53. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  54. raise InvalidArgumentError(f"{field}必须是正整数形式的字符串ID")
  55. return int(value)
  56. def _required_text(
  57. value: Any,
  58. *,
  59. field: str,
  60. max_length: int,
  61. ) -> str:
  62. if not isinstance(value, str) or not value.strip():
  63. raise InvalidArgumentError(f"{field}不能为空")
  64. normalized = value.strip()
  65. if len(normalized) > max_length:
  66. raise InvalidArgumentError(f"{field}长度不能超过{max_length}")
  67. return normalized
  68. def _category_type(value: Any) -> str:
  69. if not isinstance(value, str):
  70. raise InvalidArgumentError("categoryType必须是字符串")
  71. try:
  72. return CategoryType(value).value
  73. except ValueError as exc:
  74. raise InvalidArgumentError("categoryType不是有效枚举值") from exc
  75. def _sort_no(value: Any) -> int:
  76. if type(value) is not int:
  77. raise InvalidArgumentError("sortNo必须是整数")
  78. return value
  79. def _row_version(value: Any) -> int:
  80. if type(value) is not int or value < 0:
  81. raise InvalidArgumentError("rowVersion必须是非负整数")
  82. return value
  83. def _bool_field(value: Any, *, field: str) -> bool:
  84. if not isinstance(value, bool):
  85. raise InvalidArgumentError(f"{field}必须是布尔值")
  86. return value
  87. def _nullable_text(value: Any, *, field: str, max_length: int) -> str | None:
  88. if value is None:
  89. return None
  90. if not isinstance(value, str):
  91. raise InvalidArgumentError(f"{field}必须是字符串或null")
  92. normalized = value.strip()
  93. if not normalized:
  94. return None
  95. if len(normalized) > max_length:
  96. raise InvalidArgumentError(f"{field}长度不能超过{max_length}")
  97. return normalized
  98. def _exact_payload(payload: Any, expected: set[str]) -> dict[str, Any]:
  99. if not isinstance(payload, dict) or set(payload) != expected:
  100. raise InvalidArgumentError(
  101. "请求体字段必须且只能包含:" + "、".join(sorted(expected))
  102. )
  103. return payload
  104. def _path(parent: Category | None, category_name: str) -> str:
  105. value = (
  106. f"{parent.category_path.rstrip('/')}/{category_name}"
  107. if parent is not None
  108. else f"/{category_name}"
  109. )
  110. if len(value) > 1000:
  111. raise InvalidArgumentError("categoryPath长度超过数据库限制")
  112. return value
  113. def _dto(
  114. category: Category,
  115. *,
  116. parent_id: int | None = None,
  117. document_count: int | None = None,
  118. children: list[dict[str, object]] | None = None,
  119. ) -> dict[str, object]:
  120. return {
  121. "id": serialize_id(category.id),
  122. "categoryCode": category.category_code,
  123. "categoryName": category.category_name,
  124. "categoryType": category.category_type,
  125. "parentId": serialize_id(
  126. category.parent_id if parent_id is None else parent_id
  127. ),
  128. "categoryPath": category.category_path,
  129. "sortNo": category.sort_no,
  130. "documentCount": (
  131. category.document_count
  132. if document_count is None
  133. else document_count
  134. ),
  135. "flatMode": bool(category.flat_mode),
  136. "typeDisplayName": category.type_display_name,
  137. "rowVersion": category.row_version,
  138. "children": children or [],
  139. }
  140. def _children_map(categories: list[Category]) -> dict[int | None, list[Category]]:
  141. children: dict[int | None, list[Category]] = {}
  142. for category in categories:
  143. children.setdefault(category.parent_id, []).append(category)
  144. return children
  145. def _descendants(
  146. category_id: int,
  147. children: dict[int | None, list[Category]],
  148. ) -> list[Category]:
  149. result: list[Category] = []
  150. pending = deque(children.get(category_id, []))
  151. seen = {category_id}
  152. while pending:
  153. category = pending.popleft()
  154. if category.id in seen:
  155. raise ConflictError("分类树存在循环关系")
  156. seen.add(category.id)
  157. result.append(category)
  158. pending.extend(children.get(category.id, []))
  159. return result
  160. def category_tree(
  161. *,
  162. keyword: str | None,
  163. status: str | None,
  164. ) -> list[dict[str, object]]:
  165. if status:
  166. try:
  167. normalized_status = EnabledStatus(status).value
  168. except ValueError as exc:
  169. raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
  170. else:
  171. normalized_status = EnabledStatus.ENABLED.value
  172. categories = db.session.scalars(
  173. select(Category)
  174. .where(Category.is_deleted.is_(False))
  175. .order_by(Category.sort_no.asc(), Category.id.asc())
  176. ).all()
  177. # 计数口径:本分类与全部后代分类下的所有有效主体方案(MAIN),
  178. # 不按密级/状态/可见性/授权过滤——与"列表全量可见、内容访问受控"一致,
  179. # 括号数字反映分类真实挂载规模,无权限方案由列表/详情接口单独控制。
  180. direct_main_counts = dict(
  181. db.session.execute(
  182. select(Document.category_id, func.count(Document.id))
  183. .where(
  184. Document.category_id.is_not(None),
  185. Document.document_type == DocumentType.MAIN.value,
  186. Document.is_deleted.is_(False),
  187. )
  188. .group_by(Document.category_id)
  189. ).all()
  190. )
  191. by_id = {item.id: item for item in categories}
  192. children = _children_map(categories)
  193. eligible_ids = {
  194. item.id for item in categories if item.status == normalized_status
  195. }
  196. if keyword and keyword.strip():
  197. needle = keyword.strip().casefold()
  198. matched_ids = {
  199. item.id
  200. for item in categories
  201. if item.id in eligible_ids
  202. and (
  203. needle in item.category_name.casefold()
  204. or needle in item.category_code.casefold()
  205. )
  206. }
  207. included_ids = set(matched_ids)
  208. for matched_id in tuple(matched_ids):
  209. included_ids.update(
  210. descendant.id
  211. for descendant in _descendants(matched_id, children)
  212. if descendant.id in eligible_ids
  213. )
  214. parent_id = by_id[matched_id].parent_id
  215. while parent_id is not None and parent_id in by_id:
  216. included_ids.add(parent_id)
  217. parent_id = by_id[parent_id].parent_id
  218. else:
  219. included_ids = eligible_ids
  220. # 沿父链向上累加时穿过停用分类节点,保证父分类计数 = 子树全部主案数。
  221. subtree_counts = {category.id: 0 for category in categories}
  222. for category_id, count in direct_main_counts.items():
  223. current_id = category_id
  224. seen: set[int] = set()
  225. while current_id in subtree_counts and current_id not in seen:
  226. seen.add(current_id)
  227. subtree_counts[current_id] += int(count)
  228. current_id = by_id[current_id].parent_id
  229. nodes: dict[int, dict[str, object]] = {}
  230. for category in categories:
  231. if category.id in included_ids:
  232. response_parent_id = (
  233. category.parent_id if category.parent_id in included_ids else None
  234. )
  235. nodes[category.id] = _dto(
  236. category,
  237. document_count=subtree_counts.get(category.id, 0),
  238. )
  239. nodes[category.id]["parentId"] = serialize_id(response_parent_id)
  240. roots: list[dict[str, object]] = []
  241. for category in categories:
  242. node = nodes.get(category.id)
  243. if node is None:
  244. continue
  245. if category.parent_id in nodes:
  246. nodes[category.parent_id]["children"].append(node)
  247. else:
  248. roots.append(node)
  249. return roots
  250. def _valid_parent(session, parent_id: int | None) -> Category | None:
  251. if parent_id is None:
  252. return None
  253. parent = session.scalar(
  254. select(Category)
  255. .where(
  256. Category.id == parent_id,
  257. Category.is_deleted.is_(False),
  258. Category.status == EnabledStatus.ENABLED.value,
  259. )
  260. .with_for_update()
  261. )
  262. if parent is None:
  263. raise InvalidArgumentError("parentId必须指向有效且已启用的分类")
  264. direct_main_count = session.scalar(
  265. select(func.count(Document.id)).where(
  266. Document.category_id == parent.id,
  267. Document.document_type == DocumentType.MAIN.value,
  268. Document.is_deleted.is_(False),
  269. )
  270. ) or 0
  271. if direct_main_count:
  272. child_count = session.scalar(
  273. select(func.count(Category.id)).where(
  274. Category.parent_id == parent.id,
  275. Category.is_deleted.is_(False),
  276. Category.status == EnabledStatus.ENABLED.value,
  277. )
  278. ) or 0
  279. raise CategoryNotLeafError(
  280. details={
  281. "categoryId": serialize_id(parent.id),
  282. "childCategoryCount": int(child_count),
  283. }
  284. )
  285. return parent
  286. def create_category(payload: Any) -> dict[str, object]:
  287. body = _exact_payload(payload, CREATE_FIELDS)
  288. category_code = _required_text(
  289. body["categoryCode"],
  290. field="categoryCode",
  291. max_length=64,
  292. )
  293. category_name = _required_text(
  294. body["categoryName"],
  295. field="categoryName",
  296. max_length=128,
  297. )
  298. category_type = _category_type(body["categoryType"])
  299. parent_id = _string_id(body["parentId"], field="parentId", nullable=True)
  300. sort_no = _sort_no(body["sortNo"])
  301. # flat_mode 仅一级分类(parent_id IS NULL)有效;子分类强制 False
  302. flat_mode_input = _bool_field(body["flatMode"], field="flatMode")
  303. effective_flat_mode = False if parent_id is not None else flat_mode_input
  304. # type_display_name 仅一级分类有效;子分类强制 None
  305. type_display_name_input = _nullable_text(
  306. body["typeDisplayName"], field="typeDisplayName", max_length=128
  307. )
  308. effective_type_display_name = (
  309. None if parent_id is not None else type_display_name_input
  310. )
  311. actor_id = get_auth_context().user_id
  312. try:
  313. with transaction() as session:
  314. require_category_editor(session)
  315. if session.scalar(
  316. select(Category.id).where(Category.category_code == category_code)
  317. ) is not None:
  318. raise ConflictError("分类编码已存在")
  319. parent = _valid_parent(session, parent_id)
  320. category = Category(
  321. category_code=category_code,
  322. category_name=category_name,
  323. category_type=category_type,
  324. parent_id=parent_id,
  325. category_path=_path(parent, category_name),
  326. sort_no=sort_no,
  327. document_count=0,
  328. status=EnabledStatus.ENABLED.value,
  329. flat_mode=effective_flat_mode,
  330. type_display_name=effective_type_display_name,
  331. created_by=actor_id,
  332. updated_by=actor_id,
  333. )
  334. session.add(category)
  335. session.flush()
  336. session.add(
  337. business_audit(
  338. action=AuditAction.CREATE_CATEGORY,
  339. target=AuditTarget.CATEGORY,
  340. target_id=category.id,
  341. target_name=category.category_name,
  342. detail={
  343. "categoryCode": category.category_code,
  344. "categoryType": category.category_type,
  345. "parentId": serialize_id(category.parent_id),
  346. "categoryPath": category.category_path,
  347. "sortNo": category.sort_no,
  348. "flatMode": bool(category.flat_mode),
  349. "typeDisplayName": category.type_display_name,
  350. },
  351. )
  352. )
  353. return _dto(category)
  354. except IntegrityError as exc:
  355. raise ConflictError("分类编码已存在") from exc
  356. def _locked_categories(session) -> list[Category]:
  357. return session.scalars(
  358. select(Category)
  359. .where(Category.is_deleted.is_(False))
  360. .order_by(Category.id.asc())
  361. .with_for_update()
  362. ).all()
  363. def update_category(category_id: int, payload: Any) -> dict[str, object]:
  364. body = _exact_payload(payload, UPDATE_FIELDS)
  365. category_name = _required_text(
  366. body["categoryName"],
  367. field="categoryName",
  368. max_length=128,
  369. )
  370. category_type = _category_type(body["categoryType"])
  371. parent_id = _string_id(body["parentId"], field="parentId", nullable=True)
  372. sort_no = _sort_no(body["sortNo"])
  373. flat_mode_input = _bool_field(body["flatMode"], field="flatMode")
  374. # type_display_name 仅一级分类有效;子分类强制 None(与 flat_mode 口径一致)
  375. type_display_name_input = _nullable_text(
  376. body["typeDisplayName"], field="typeDisplayName", max_length=128
  377. )
  378. expected_version = _row_version(body["rowVersion"])
  379. actor_id = get_auth_context().user_id
  380. now = _utc_now()
  381. with transaction() as session:
  382. require_category_editor(session)
  383. categories = _locked_categories(session)
  384. by_id = {item.id: item for item in categories}
  385. category = by_id.get(category_id)
  386. if category is None:
  387. raise ResourceNotFoundError("分类不存在")
  388. if category.row_version != expected_version:
  389. raise ConflictError(
  390. "数据已被其他用户修改,请刷新后重试",
  391. details={"currentRowVersion": category.row_version},
  392. )
  393. children = _children_map(categories)
  394. descendants = _descendants(category.id, children)
  395. descendant_ids = {item.id for item in descendants}
  396. if parent_id == category.id:
  397. raise InvalidArgumentError("parentId不能等于当前分类ID")
  398. if parent_id in descendant_ids:
  399. raise InvalidArgumentError("分类不能移动到自己的后代节点下")
  400. parent = None
  401. if parent_id is not None:
  402. parent = by_id.get(parent_id)
  403. if (
  404. parent is None
  405. or parent.is_deleted
  406. or parent.status != EnabledStatus.ENABLED.value
  407. ):
  408. raise InvalidArgumentError("parentId必须指向有效且已启用的分类")
  409. if parent_id != category.parent_id:
  410. direct_main_count = session.scalar(
  411. select(func.count(Document.id)).where(
  412. Document.category_id == parent.id,
  413. Document.document_type == DocumentType.MAIN.value,
  414. Document.is_deleted.is_(False),
  415. )
  416. ) or 0
  417. if direct_main_count:
  418. child_count = session.scalar(
  419. select(func.count(Category.id)).where(
  420. Category.parent_id == parent.id,
  421. Category.is_deleted.is_(False),
  422. Category.status == EnabledStatus.ENABLED.value,
  423. )
  424. ) or 0
  425. raise CategoryNotLeafError(
  426. details={
  427. "categoryId": serialize_id(parent.id),
  428. "childCategoryCount": int(child_count),
  429. }
  430. )
  431. # flat_mode 切换保护:仅一级分类可启用;false→true 时拒绝已存在挂载数据
  432. new_flat_mode = False if category.parent_id is not None else flat_mode_input
  433. new_type_display_name = (
  434. None if category.parent_id is not None else type_display_name_input
  435. )
  436. if new_flat_mode and not category.flat_mode:
  437. check_category_ids = descendant_ids | {category.id}
  438. main_plan_ids_subq = select(Document.id).where(
  439. Document.document_type == DocumentType.MAIN.value,
  440. Document.is_deleted.is_(False),
  441. Document.category_id.in_(check_category_ids),
  442. )
  443. has_sub_plan = session.scalar(
  444. select(
  445. exists().where(
  446. Document.document_type == DocumentType.SUB_PLAN.value,
  447. Document.is_deleted.is_(False),
  448. Document.parent_document_id.in_(main_plan_ids_subq),
  449. )
  450. )
  451. )
  452. has_attachment = session.scalar(
  453. select(
  454. exists().where(
  455. AttachmentBinding.is_deleted.is_(False),
  456. AttachmentBinding.main_document_id.in_(
  457. main_plan_ids_subq
  458. ),
  459. )
  460. )
  461. )
  462. has_sub_mount = session.scalar(
  463. select(
  464. exists().where(
  465. SubPlanBinding.is_deleted.is_(False),
  466. SubPlanBinding.main_document_id.in_(
  467. main_plan_ids_subq
  468. ),
  469. )
  470. )
  471. )
  472. if has_sub_plan or has_attachment or has_sub_mount:
  473. raise CategoryFlatModeConflictError(
  474. details={
  475. "categoryId": serialize_id(category.id),
  476. "hasSubPlan": bool(has_sub_plan),
  477. "hasAttachment": bool(has_attachment),
  478. "hasSubPlanMount": bool(has_sub_mount),
  479. }
  480. )
  481. before = {
  482. "categoryName": category.category_name,
  483. "categoryType": category.category_type,
  484. "parentId": serialize_id(category.parent_id),
  485. "categoryPath": category.category_path,
  486. "sortNo": category.sort_no,
  487. "flatMode": bool(category.flat_mode),
  488. "typeDisplayName": category.type_display_name,
  489. "rowVersion": category.row_version,
  490. }
  491. path_changed = (
  492. category_name != category.category_name
  493. or parent_id != category.parent_id
  494. )
  495. category.category_name = category_name
  496. category.category_type = category_type
  497. category.parent_id = parent_id
  498. category.category_path = _path(parent, category_name)
  499. category.flat_mode = new_flat_mode
  500. category.type_display_name = new_type_display_name
  501. category.sort_no = sort_no
  502. category.updated_by = actor_id
  503. category.updated_at = now
  504. category.row_version += 1
  505. affected_categories = [category]
  506. if path_changed:
  507. pending = deque([category])
  508. while pending:
  509. current = pending.popleft()
  510. for child in children.get(current.id, []):
  511. child.category_path = _path(current, child.category_name)
  512. child.updated_by = actor_id
  513. child.updated_at = now
  514. child.row_version += 1
  515. affected_categories.append(child)
  516. pending.append(child)
  517. affected_document_count = 0
  518. if path_changed:
  519. for affected in affected_categories:
  520. result = session.execute(
  521. update(Document)
  522. .where(
  523. Document.category_id == affected.id,
  524. Document.is_deleted.is_(False),
  525. Document.document_type.in_(
  526. [
  527. DocumentType.MAIN.value,
  528. DocumentType.SUB_PLAN.value,
  529. ]
  530. ),
  531. )
  532. .values(
  533. category_name=affected.category_name,
  534. category_path=affected.category_path,
  535. updated_by=actor_id,
  536. updated_at=now,
  537. row_version=Document.row_version + 1,
  538. )
  539. )
  540. affected_document_count += result.rowcount
  541. after = {
  542. "categoryName": category.category_name,
  543. "categoryType": category.category_type,
  544. "parentId": serialize_id(category.parent_id),
  545. "categoryPath": category.category_path,
  546. "sortNo": category.sort_no,
  547. "flatMode": bool(category.flat_mode),
  548. "typeDisplayName": category.type_display_name,
  549. "rowVersion": category.row_version,
  550. }
  551. session.add(
  552. business_audit(
  553. action=AuditAction.EDIT_CATEGORY,
  554. target=AuditTarget.CATEGORY,
  555. target_id=category.id,
  556. target_name=category.category_name,
  557. detail={
  558. "before": before,
  559. "after": after,
  560. "updatedDescendantCount": len(affected_categories) - 1,
  561. "updatedDocumentCount": affected_document_count,
  562. },
  563. )
  564. )
  565. return _dto(category)
  566. def delete_category(category_id: int, row_version: int) -> dict[str, object]:
  567. actor_id = get_auth_context().user_id
  568. now = _utc_now()
  569. with transaction() as session:
  570. require_category_editor(session)
  571. categories = _locked_categories(session)
  572. by_id = {item.id: item for item in categories}
  573. category = by_id.get(category_id)
  574. if category is None:
  575. raise ResourceNotFoundError("分类不存在")
  576. if category.row_version != row_version:
  577. raise ConflictError(
  578. "数据已被其他用户修改,请刷新后重试",
  579. details={"currentRowVersion": category.row_version},
  580. )
  581. descendants = _descendants(category.id, _children_map(categories))
  582. child_count = len(descendants)
  583. document_count = (
  584. session.scalar(
  585. select(func.count(Document.id)).where(
  586. Document.category_id == category.id,
  587. Document.is_deleted.is_(False),
  588. Document.document_type.in_(
  589. [
  590. DocumentType.MAIN.value,
  591. DocumentType.SUB_PLAN.value,
  592. ]
  593. ),
  594. )
  595. )
  596. or 0
  597. )
  598. if child_count or document_count:
  599. raise CategoryInUseError(
  600. details={
  601. "categoryId": serialize_id(category.id),
  602. "childCategoryCount": child_count,
  603. "documentCount": document_count,
  604. }
  605. )
  606. target_name = category.category_name
  607. category.is_deleted = True
  608. category.deleted_at = now
  609. category.updated_at = now
  610. category.updated_by = actor_id
  611. category.row_version += 1
  612. session.add(
  613. business_audit(
  614. action=AuditAction.DELETE_CATEGORY,
  615. target=AuditTarget.CATEGORY,
  616. target_id=category.id,
  617. target_name=target_name,
  618. detail={
  619. "categoryCode": category.category_code,
  620. "categoryPath": category.category_path,
  621. "rowVersion": category.row_version,
  622. },
  623. )
  624. )
  625. return {"id": serialize_id(category.id), "deleted": True}
  626. REORDER_FIELDS = {"parentId", "orderedIds"}
  627. def reorder_categories(payload: Any) -> dict[str, int]:
  628. """管理员拖动重排分类:同一父分类下的同级范围排序。
  629. - ``parentId`` 指明排序范围:null 为顶级分类,否则为该父分类的直接子级;
  630. - ``orderedIds`` 为拖动后的目标顺序(允许只提交范围内部分ID,
  631. 未提交的槽位保持相对顺序,仅被提交ID"置换"填充),按步长10全量重编号。
  632. """
  633. if not isinstance(payload, dict) or set(payload) != REORDER_FIELDS:
  634. raise InvalidArgumentError("请求字段必须且只能包含parentId和orderedIds")
  635. parent_id = _string_id(payload["parentId"], field="parentId", nullable=True)
  636. raw_ids = payload["orderedIds"]
  637. if not isinstance(raw_ids, list) or len(raw_ids) < 2:
  638. raise InvalidArgumentError("orderedIds必须是至少包含2个ID的数组")
  639. ordered_ids: list[int] = []
  640. seen: set[int] = set()
  641. for index, raw_id in enumerate(raw_ids):
  642. category_id = _string_id(raw_id, field=f"orderedIds[{index}]")
  643. if category_id not in seen:
  644. ordered_ids.append(category_id)
  645. seen.add(category_id)
  646. context = get_auth_context()
  647. def operation() -> dict[str, int]:
  648. with transaction() as session:
  649. require_category_editor(session)
  650. if parent_id is not None:
  651. parent = session.get(Category, parent_id)
  652. if parent is None or parent.is_deleted:
  653. raise ResourceNotFoundError("排序范围的父分类不存在或已删除")
  654. siblings = session.scalars(
  655. select(Category)
  656. .where(
  657. Category.is_deleted.is_(False),
  658. Category.parent_id.is_(None)
  659. if parent_id is None
  660. else Category.parent_id == parent_id,
  661. )
  662. .order_by(Category.sort_no.asc(), Category.id.asc())
  663. .with_for_update()
  664. ).all()
  665. sibling_ids = {item.id for item in siblings}
  666. if any(item_id not in sibling_ids for item_id in ordered_ids):
  667. raise InvalidArgumentError("orderedIds包含不在同级范围内的分类")
  668. all_ids = [item.id for item in siblings]
  669. slot_ids = sorted(ordered_ids, key=all_ids.index)
  670. new_order = list(all_ids)
  671. for position, item_id in zip(
  672. (all_ids.index(iid) for iid in slot_ids), ordered_ids
  673. ):
  674. new_order[position] = item_id
  675. target_sort_by_id = {
  676. item_id: (index + 1) * 10
  677. for index, item_id in enumerate(new_order)
  678. }
  679. now = _utc_now()
  680. reordered_count = 0
  681. for item in siblings:
  682. target = target_sort_by_id[item.id]
  683. if item.sort_no == target:
  684. continue
  685. item.sort_no = target
  686. item.updated_by = context.user_id
  687. item.updated_at = now
  688. item.row_version += 1
  689. reordered_count += 1
  690. first = next(item for item in siblings if item.id == ordered_ids[0])
  691. session.add(
  692. business_audit(
  693. action=AuditAction.REORDER_CATEGORY,
  694. target=AuditTarget.CATEGORY,
  695. target_id=first.id,
  696. target_name=first.category_name,
  697. detail={
  698. "scope": serialize_id(parent_id)
  699. if parent_id is not None
  700. else "ROOT",
  701. "orderedIds": [serialize_id(iid) for iid in ordered_ids],
  702. "reorderedCount": reordered_count,
  703. },
  704. )
  705. )
  706. return {"reorderedCount": reordered_count}
  707. return execute_with_deadlock_retry(operation)
  708. def parse_category_id(value: str) -> int:
  709. result = _string_id(value, field="id")
  710. assert result is not None
  711. return result
  712. def parse_delete_row_version(value: str | None) -> int:
  713. if value is None or not value.isdecimal():
  714. raise InvalidArgumentError("rowVersion必须是非负整数")
  715. return int(value)