分類樹管理重構(gòu):以 `spec.parent` 為基準(zhǔn)的 Console 樹 API 與單次位置更新設(shè)計(jì))
Halo 控制臺(tái)分類樹管理重構(gòu)以spec.parent為基準(zhǔn)的 Console 樹 API 與單次位置更新設(shè)計(jì)【免費(fèi)下載鏈接】haloHalo 是一款強(qiáng)大易用的開源建站工具從個(gè)人博客、知識(shí)庫(kù)到企業(yè)官網(wǎng)、在線商城Halo 都能助您輕松實(shí)現(xiàn)一站式滿足您的多樣化建站需求。項(xiàng)目地址: https://gitcode.com/GitHub_Trending/ha/halo分類目錄樹是 Halo 內(nèi)容管理的核心交互之一從拖拽排序、多級(jí)嵌套到共享的分類選擇器都依賴一套可編輯的分類層級(jí)。Halo 早期的實(shí)現(xiàn)把層級(jí)邏輯大量留在前端 Vue 工具中——先拉平分類列表、本地構(gòu)建可編輯樹、拖拽后前端自行重算所有兄弟節(jié)點(diǎn)priority、再把整棵樹拍平為一批 JSON Patch 并發(fā)提交。本文基于 spec.md 的需求基線并結(jié)合當(dāng)前倉(cāng)庫(kù)中的源碼完整講解 Halo 如何把這條職責(zé)邊界后移到后端新增返回規(guī)范化canonical分類樹的 Console 樹 API以及一次僅移動(dòng)一個(gè)分類的position更新 API讓前端只保留交互狀態(tài)。讀完你將掌握這兩類新 API 的路徑、請(qǐng)求語(yǔ)義、后端校驗(yàn)與優(yōu)先級(jí)重算規(guī)則以及前端如何圍繞它們重構(gòu)。一、重構(gòu)背景把層級(jí)所有權(quán)從 Vue 工具交還給后端在本次改動(dòng)之前Console 分類管理的調(diào)用鏈大致是列出全部分類flat 列表在前端Vue本地構(gòu)建可編輯樹拖拽后由前端遍歷差異為受影響兄弟列表重算每個(gè)spec.priority將整棵樹扁平化回多個(gè) Category并對(duì)這些分類并發(fā)發(fā)送層次 JSON Patch 請(qǐng)求。design.md對(duì)該問題給出了明確的判定前端擁有過多層級(jí)行為。這種做法的隱患是規(guī)范排序規(guī)則被復(fù)制到了 UI 層且一次拖拽可能產(chǎn)生多個(gè)部分寫入存在部分保存失敗的失敗模式。與此相對(duì)Console 菜單層級(jí)menu hierarchy的改造已經(jīng)建立了更合理的邊界——后端 Console API 返回規(guī)范化樹數(shù)據(jù)并接受單個(gè)相對(duì)位移請(qǐng)求前端只保留交互狀態(tài)。本次簡(jiǎn)化 Console 分類樹管理改動(dòng)正是讓分類管理遵循同一模型只是分類沒有 menu 那樣的歸屬字段menu 由 owning menu field 定位層級(jí)因此需要專門的分類樹接口詳見 design.md。二、數(shù)據(jù)模型前提spec.parent與spec.priority是唯一層次寫入點(diǎn)本次 Console 改造并非憑空發(fā)明新字段它建立在分類層級(jí)以Category.spec.parent為運(yùn)行時(shí)唯一事實(shí)來源這一更大的數(shù)據(jù)模型遷移之上。在 Category.java 中可以看到該模型的關(guān)鍵設(shè)計(jì)spec.parent父分類的metadata.name根分類不設(shè)置此字段注釋明確 Root categories leave this unsetspec.priority同級(jí)排序優(yōu)先級(jí)默認(rèn)值0spec.children保留的舊字段已被Deprecated(since 2.26.0)標(biāo)記并在 schema 上聲明deprecated true層級(jí)不再?gòu)乃茖?dǎo)常量 HIERARCHY_MIGRATED_LABELcontent.halo.run/category-hierarchy-migrated用于標(biāo)記已完成遷移的分類供遷移組件判斷與重試。在本次 Console 重構(gòu)的需求邊界內(nèi)所有關(guān)于把分類放到哪里、排在哪位的寫操作都必須收斂為對(duì)spec.parent與spec.priority的更新并且這些計(jì)算只能發(fā)生在后端。舊的spec.children在本改動(dòng)中既不會(huì)被寫入、也不會(huì)被重算見 design.md 的 Non-Goals。三、讀取側(cè)Console 分類樹 API 返回規(guī)范化層級(jí)3.1 端點(diǎn)定義需求 Console category tree APIs provide canonical hierarchy 要求系統(tǒng)提供讀取與更新可編輯分類層級(jí)的 Console API。它落地為兩條自定義端點(diǎn)定義在 CategoryEndpoint.java 中方法路徑operationId職責(zé)GETapis/api.console.halo.run/v1alpha1/categories/-/treeListCategoryTree將分類以規(guī)范化樹返回供 Console 分類管理使用PUTapis/api.console.halo.run/v1alpha1/categories/{name}/positionUpdateCategoryPosition在 Console 樹內(nèi)移動(dòng)一個(gè)分類選擇position位移端點(diǎn) 返回整棵樹而不是直接 PUT 一整棵樹design.md給出了理由拖拽在語(yǔ)義上是一次單一用戶動(dòng)作專用位置端點(diǎn)比接受整棵樹更清晰design.md Decision 1。3.2 響應(yīng)節(jié)點(diǎn)形狀CategoryTreeNode樹響應(yīng)不是復(fù)用主題側(cè) VO而是專門的 Console DTO CategoryTreeNode.javaCategoryTreeNode { Category category; ListCategoryTreeNode children; }設(shè)計(jì)文檔對(duì)比了備選方案復(fù)用CategoryTreeVo或直接在 Category 擴(kuò)展對(duì)象里塞children。兩者都被否決CategoryTreeVo面向主題渲染含parentName、文章計(jì)數(shù)投影等主題輸出關(guān)切在 API 響應(yīng)里直接給 Category 加children則會(huì)模糊擴(kuò)展?fàn)顟B(tài)與可編輯樹視圖數(shù)據(jù)的界限。因此新增的CategoryTreeNode節(jié)點(diǎn)包含原始 Category 擴(kuò)展與只讀子節(jié)點(diǎn)列表design.md Decision 2。3.3children是視圖數(shù)據(jù)不是存儲(chǔ)數(shù)據(jù)spec 中有一個(gè)極易混淆的要點(diǎn)見 spec.md返回的樹節(jié)點(diǎn)里確實(shí)叫children但它是視圖數(shù)據(jù)view data絕不寫回Category.spec.children。也就是說這個(gè)children與已棄用的存儲(chǔ)字段同名卻不同義存儲(chǔ)的層次關(guān)系完全在spec.parent上表達(dá)樹中的嵌套只是后端按parent組裝出來的投影。需求原文措辭 SHALL be view data and SHALL NOT write toCategory.spec.children 正是在防止實(shí)現(xiàn)者順手把樹又拍平回舊字段。3.4 建樹容錯(cuò)無(wú)效父引用一律按根節(jié)點(diǎn)渲染真實(shí)生產(chǎn)數(shù)據(jù)可能被插件或歷史導(dǎo)入污染。為此樹構(gòu)建必須容錯(cuò)渲染。需求 Console category tree handles invalid parent referencesspec.md要求當(dāng)某個(gè)分類存在缺失父、自引用、循環(huán)父鏈時(shí)受影響分類應(yīng)被渲染為根分類其余鏈條合法的后代仍正常返回。這一邏輯在 CategoryConsoleService.listToTree 中實(shí)現(xiàn)其算法分三步validParentMap()只登記父存在、且父名不等于自身的邊L156-L165缺失父與自引用自然被過濾cyclicNames()沿著父鏈做環(huán)檢測(cè)將處于環(huán)中的節(jié)點(diǎn)名集合標(biāo)記出來L167-L183組裝子樹后只有parentMap中不存在父、或?qū)儆诃h(huán)的節(jié)點(diǎn)被提升為根L146-L153從而保證 Console 樹在異常數(shù)據(jù)下依然可用。3.5 規(guī)范化排序規(guī)則需求 Console category tree is ordered canonicallyspec.md規(guī)定同一父下多個(gè)分類依次按priority、創(chuàng)建時(shí)間戳、metadata.name排序。這正是 defaultCategoryComparator() 的鏈?zhǔn)奖容^器隨后sortTree遞歸應(yīng)用到每一層L185-L188。對(duì)priority缺省的分類取0L203-L207創(chuàng)建時(shí)間用nullsFirst兜底。換句話說同級(jí)的先后順序從此只有后端一處實(shí)現(xiàn)前端無(wú)需再?gòu)?fù)制任何排序口徑。3.6 共享分類選擇器統(tǒng)一走樹需求 Category select uses canonical treespec.md面向console-src下的共享categorySelect組件渲染選項(xiàng)、鍵盤導(dǎo)航、搜索結(jié)果路徑都必須使用 Console 樹 API 返回的樹。spec 同時(shí)允許前端在本地把樹拉平flatten用于搜索與選中值解析——這體現(xiàn)了明確的邊界樹的來源與結(jié)構(gòu)由后端權(quán)威給出扁平化只是本地索引型視圖。四、寫入側(cè)一次移動(dòng)一個(gè)分類的 position API4.1 相對(duì)位置請(qǐng)求parentNamebeforeName移動(dòng)語(yǔ)義的關(guān)鍵在請(qǐng)求體設(shè)計(jì)。CategoryPositionRequest是只有兩個(gè)可空字段的 recordCategoryPositionRequest.javarecord CategoryPositionRequest(Nullable String parentName, Nullable String beforeName) {}兩個(gè)字段的語(yǔ)義組合完整覆蓋了三種移動(dòng)這些場(chǎng)景被逐條固化為 spec 需求parentNamebeforeName效果spec 場(chǎng)景目標(biāo)父名目標(biāo)前一兄弟名移動(dòng)到該父下、指定兄弟之前Console moves a category by relative position目標(biāo)父名未設(shè)置/null追加到該父兄弟列表末尾Category position update appends to a sibling list未設(shè)置/null任意服務(wù)端不校驗(yàn)移除spec.parent成為根分類追加到根兄弟列表末尾Category position update moves category to root實(shí)現(xiàn)入口在 CategoryConsoleService.updatePosition真正執(zhí)行的是applyMoveL62-L122。一次成功的位移會(huì)返回更新后的完整規(guī)范化樹前端直接以該樹替換本地狀態(tài)因此位置語(yǔ)義是相對(duì)位移、絕對(duì)返回。4.2 服務(wù)端校驗(yàn)四類拒絕spec 用四個(gè)場(chǎng)景明確了 position 更新的非法輸入均以ServerWebInputExceptionHTTP 400拒絕逐條對(duì)應(yīng)applyMove中的檢查無(wú)效相對(duì)對(duì)象parentName或beforeName指向不存在的分類 → 拒絕L79-L89被移動(dòng)的分類本身不存在則返回 404L70-L73目標(biāo)同級(jí)不一致beforeName在應(yīng)用移動(dòng)后的目標(biāo)父兄弟列表中找不到 → 拒絕L101-L105成環(huán)把分類移到自己或自己的后代之下 → 拒絕。實(shí)現(xiàn)用isDescendant()沿父鏈上溯檢測(cè)L215-L230其中自身作為父L76-L78也單獨(dú)攔截附帶地若目標(biāo)父本身已處于環(huán)鏈中也會(huì)拋異常拒絕。4.3 兄弟優(yōu)先級(jí)重算連續(xù)整數(shù) 最小持久化spec Category position update recalculates sibling prioritiesspec.md規(guī)定了寫入規(guī)則與前端自算 priority 批量 patch的舊模式形成鮮明對(duì)比目標(biāo)兄弟列表被賦予從 0 開始的連續(xù)整數(shù)priorityassignPriorities按新順序下標(biāo)逐位寫入L249-L258若父級(jí)發(fā)生變化原兄弟列表同樣重算為從 0 開始的連續(xù)整數(shù)L110-L112避免留下空洞只持久化spec.parent或spec.priority確實(shí)發(fā)生變化的分類先對(duì)每個(gè)分類快照原始(parentName, priority)HierarchyStaterecordL272再經(jīng)hasHierarchyChanged()過濾出差異集后逐個(gè)client.updateL114-L121。這從設(shè)計(jì)上把寫什么、寫多少完全收歸后端前端不再需要推導(dǎo)任何持久化用的 priority 數(shù)值。4.4 并發(fā)沖突樂觀鎖重試 409分類層級(jí)允許多人同時(shí)編輯后端寫操作按擴(kuò)展機(jī)制攜帶版本號(hào)并發(fā)沖突會(huì)拋OptimisticLockingFailureException。處理策略對(duì)應(yīng) updatePosition是退避重試1 次Retry.backoff(1, Duration.ofMillis(100))重試耗盡后映射為409 Conflict響應(yīng)體注明 Category position update conflicted.前端收到失敗后重取規(guī)范化樹見下節(jié)讓雙方狀態(tài)重新對(duì)齊。五、前端改造只保留交互狀態(tài)本次改動(dòng)的需求集中條目 Console category management writes parent references 從加載創(chuàng)建根/子分類拖拽保存保存失敗移到根等維度約束了 Console 行為spec.md。5.1 狀態(tài)入口usePostCategory 消費(fèi)樹 API分類管理的數(shù)據(jù)入口 composable use-post-category.ts 與需求一一對(duì)應(yīng)通過生成的 Console API client 調(diào)用consoleApiClient.content.category.listCategoryTree()獲取樹queryKey 為[post-categories]setCategoriesTree同步維護(hù)三份狀態(tài)權(quán)威樹categoriesTree、拖拽前的樹快照previousCategoriesTreecloneDeep深拷貝、供過濾/搜索/選中解析使用的拉平數(shù)組categoriesL16-L20樹中若存在帶刪除時(shí)間戳或尚無(wú)permalinkstatus 未就緒的異常分類則以 1 秒間隔自動(dòng)輪詢刷新L29-L35。spec 中 Console SHALL NOT build the editable tree from a flat Category list 由此落實(shí)本地只做拉平索引flattenCategoryTreeNodes位于 categories/utils/index.ts絕不再本地拼接可編輯樹。5.2 拖拽保存 派生一條 position 請(qǐng)求Console saves drag-and-drop hierarchy 場(chǎng)景spec.md定義了拖拽保存的理想流程管理員把分類拖到新位置Console 發(fā)送單次position 更新含目標(biāo)父與目標(biāo)前一兄弟前端不自行計(jì)算spec.priority持久化值前端不用層級(jí) JSON Patch 批量 patch 分類前端用后端返回的規(guī)范化樹替換本地樹。previousCategoriesTree快照正是為步驟 2 服務(wù)的比較拖拽前后兩棵樹推導(dǎo)出哪一個(gè)分類、移動(dòng)到哪個(gè) parent、插在哪個(gè) before 之前的唯一移動(dòng)請(qǐng)求。若差異無(wú)法用一個(gè)單一移動(dòng)解釋例如出現(xiàn)意外的多節(jié)點(diǎn)變化設(shè)計(jì)文檔的風(fēng)險(xiǎn)章節(jié)給出的對(duì)策是放棄推測(cè)、直接重取樹絕不以模糊的本地狀態(tài)作為持久化結(jié)果design.md Risks。5.3 失敗回退重載權(quán)威樹Console handles drag-and-drop save failurespec.md與 5.2 共同組成一致性閉環(huán)position 請(qǐng)求一旦失敗Console 必須重新加載規(guī)范化樹不得保留未確認(rèn)的本地拖拽狀態(tài)。同樣的原則也覆蓋移到根管理員將分類拖到根層時(shí)Console 發(fā)送parentName為 null 的 position 更新而不再通過前端 JSON Patch 移除/spec/parentspec.md——補(bǔ)丁式寫層次的做法在此被整體移除。5.4 編輯彈窗中更改父級(jí)更早的 spec 版本還細(xì)化了編輯分類彈窗改父級(jí)的交互在本 archive 對(duì)應(yīng)的 category-hierarchy/spec.md 通用需求 之外的openspec/specs正式版本中需求 Console edits category parents 與此一脈相承編輯既有分類時(shí)展示父分類下拉含無(wú)父選項(xiàng)候選來自權(quán)威樹且必須排除被編輯分類自身及其所有后代防止成環(huán)更換父級(jí)保存時(shí)發(fā)送parentName為選中父、beforeName為 null 的 position 更新追加到目標(biāo)兄弟末尾未更改父級(jí)則不發(fā)送 position 更新保留既有層級(jí)位置保存字段成功但移動(dòng)失敗時(shí)前端上報(bào)失敗并刷新權(quán)威樹。這驗(yàn)證了一個(gè)更普適的設(shè)計(jì)結(jié)論凡是會(huì)產(chǎn)生層級(jí)變化的寫操作無(wú)論入口是拖拽還是編輯彈窗最終都收斂為同一個(gè) position 更新端點(diǎn)。六、權(quán)限與范圍邊界RBAC 層面design 文檔要求分類角色模板補(bǔ)充categories/tree與categories/position兩個(gè) Console 資源design.md Decision 6。同時(shí)明確這是本改動(dòng)的 Non-GoalConsole UI 仍沿用system:posts:*權(quán)限字符串切換到system:categories:*屬于獨(dú)立的授權(quán)清理工作不在此次范圍內(nèi)不新增 Console 專屬分類創(chuàng)建 API分類創(chuàng)建依舊走核心 Category API初始 priority 的前端計(jì)算保留到后續(xù)專門的 Console create API 中解決不刪除或改寫已棄用的Category.spec.children不改變分類刪除語(yǔ)義無(wú)數(shù)據(jù)遷移本次為純代碼級(jí)重構(gòu)既有spec.parent存儲(chǔ)格式不變回滾僅需回退代碼design.md Migration Plan Rollback。七、落地順序與驗(yàn)證design 文檔給出的實(shí)施順序是先補(bǔ)后端 DTO、服務(wù)、端點(diǎn)、RBAC 規(guī)則與測(cè)試 → 重新生成 OpenAPI 文檔與 UI API 客戶端 → 更新usePostCategory()及各消費(fèi)方 → 用單次 position 更新替換批量層級(jí)保存 → 刪除不再使用的前端層級(jí)持久化工具并更新單元測(cè)試design.md Migration Plan。倉(cāng)庫(kù)中可直接核驗(yàn)的產(chǎn)物包括后端單元/端點(diǎn)測(cè)試CategoryConsoleServiceTest.java、CategoryEndpointTest.java覆蓋建樹容錯(cuò)、移動(dòng)校驗(yàn)、優(yōu)先級(jí)重算等 spec 場(chǎng)景數(shù)據(jù)遷移測(cè)試CategoryHierarchyMigrationTest.java驗(yàn)證從舊children到spec.parent的安全遷移屬于該模型的更早一環(huán)實(shí)現(xiàn)位于 CategoryHierarchyMigration.java生成的客戶端與契約category-v1alpha1-console-api.ts 與 category-position-request.ts以及 OpenAPI 文檔 apis_console.api_v1alpha1.json前端工具測(cè)試categories/utils/__tests__/index.spec.ts。八、小結(jié)一條可復(fù)用的職責(zé)邊界把本次改動(dòng)的核心契約壓縮成一句話樹只能從后端讀GET/categories/-/tree層級(jí)只能通過一次相對(duì)位移寫PUT/categories/{name}/positionspec.parent與spec.priority的重算、校驗(yàn)、排序與最小化持久化全部由服務(wù)端承擔(dān)前端只負(fù)責(zé)用返回值刷新權(quán)威狀態(tài)。這套規(guī)范化讀 單點(diǎn)相對(duì)寫 響應(yīng)替換 失敗重載的模式同樣被 Console 菜單層級(jí)管理采用是 Halo Console 處理樹形數(shù)據(jù)的一類樣板方案。理解它也就理解了如何為 Console 設(shè)計(jì)既簡(jiǎn)單又強(qiáng)一致的樹形資源接口?!久赓M(fèi)下載鏈接】haloHalo 是一款強(qiáng)大易用的開源建站工具從個(gè)人博客、知識(shí)庫(kù)到企業(yè)官網(wǎng)、在線商城Halo 都能助您輕松實(shí)現(xiàn)一站式滿足您的多樣化建站需求。項(xiàng)目地址: https://gitcode.com/GitHub_Trending/ha/halo創(chuàng)作聲明:本文部分內(nèi)容由AI輔助生成(AIGC),僅供參考