category_service.py 29 KB

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