category_service.py 28 KB

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