Files
hms/crates/erp-health/src/service/media_service.rs
iven d44c6167b1 fix: E2E 测试发现的 10 项 BUG 修复 — 全栈验证通过
P0 修复:
- 侧边栏路由不稳定: Content 区域添加 key={currentPath} 强制重渲染
- 轮播图缩略图不显示: BannerManage 导入 resolveMediaUrl + 反斜杠转正斜杠
- 超长名称导致 500: patient_handler 添加 name.len() > 255 校验
- 迁移 m20260515_000146: version 乐观锁 version+1 修复

P1 修复:
- 排班路由被冻结: routeConfig.ts 移除 /health/schedules 的 frozen 标记
- 轮播图 Switch 切换无效: 切换前先 GET 最新 version 避免乐观锁冲突
- thumbnail_url 反斜杠: media_service 存储时统一 replace('\', '/')

P2 修复:
- 预约类型 follow_up 未映射: APPOINTMENT_TYPE_MAP 补充 '随访'
- 日期选择器未汉化: DatePicker.RangePicker 添加中文 placeholder
- 轮播图 title 必填校验: banner_handler 添加空标题拒绝
- 文章分类重名: article_category_service 添加同名检查
2026-05-15 21:13:49 +08:00

794 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 媒体库 Service — 文件上传/CRUD + 缩略图/裁剪 + 文件夹管理
use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use std::path::{Path, PathBuf};
use uuid::Uuid;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::media_dto::*;
use crate::entity::{banner, media_folder, media_item};
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// 媒体文件查询
// ---------------------------------------------------------------------------
/// 分页查询媒体文件,支持按文件夹/类型/关键词/公开状态筛选
pub async fn list_media_items(
state: &HealthState,
tenant_id: Uuid,
params: MediaListParams,
) -> HealthResult<PaginatedResponse<MediaItemResp>> {
let page = params.page.unwrap_or(1).max(1);
let limit = params.page_size.unwrap_or(20).min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = media_item::Entity::find()
.filter(media_item::Column::TenantId.eq(tenant_id))
.filter(media_item::Column::DeletedAt.is_null());
if let Some(fid) = params.folder_id {
query = query.filter(media_item::Column::FolderId.eq(fid));
}
if let Some(ref ct) = params.content_type {
query = query.filter(media_item::Column::ContentType.contains(ct));
}
if let Some(pub_flag) = params.is_public {
query = query.filter(media_item::Column::IsPublic.eq(pub_flag));
}
if let Some(ref kw) = params.keyword {
query = query.filter(media_item::Column::Filename.contains(kw));
}
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(media_item::Column::CreatedAt)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data: Vec<MediaItemResp> = models.into_iter().map(model_to_resp).collect();
Ok(PaginatedResponse {
data,
total,
page,
page_size: limit,
total_pages,
})
}
/// 获取单个媒体文件详情
pub async fn get_media_item(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
) -> HealthResult<MediaItemResp> {
let model = find_media_item(state, tenant_id, id).await?;
Ok(model_to_resp(model))
}
// ---------------------------------------------------------------------------
// 文件上传
// ---------------------------------------------------------------------------
/// 上传媒体文件:保存到磁盘 → 生成缩略图 → 创建 DB 记录
#[allow(clippy::too_many_arguments)]
pub async fn upload_media(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
file_data: &[u8],
filename: &str,
content_type: &str,
folder_id: Option<Uuid>,
is_public: bool,
upload_dir: &str,
) -> HealthResult<MediaItemResp> {
let id = Uuid::now_v7();
let now = Utc::now();
// 构造存储路径: {upload_dir}/{tenant_id}/{uuid}.ext
let ext = Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin");
let relative_path = format!(
"{}/{}/{}.{}",
upload_dir.trim_end_matches('/'),
tenant_id,
id,
ext
);
let storage_path = PathBuf::from(&relative_path);
// 确保目录存在
if let Some(parent) = storage_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| HealthError::Validation(format!("创建上传目录失败: {}", e)))?;
}
// 写入文件
tokio::fs::write(&storage_path, file_data)
.await
.map_err(|e| HealthError::Validation(format!("文件写入失败: {}", e)))?;
// 读取图片尺寸
let (width, height) = read_image_dimensions(&storage_path);
// 生成缩略图
let thumbnail_path = generate_thumbnail_path(&storage_path);
if let Some(thumb_dst) = &thumbnail_path {
if let Err(e) = std::fs::create_dir_all(thumb_dst.parent().unwrap_or(Path::new("."))) {
tracing::warn!("创建缩略图目录失败: {}", e);
}
if let Err(e) = generate_thumbnail(&storage_path, thumb_dst, 200) {
tracing::warn!("生成缩略图失败 (非致命): {}", e);
}
}
let model = media_item::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
folder_id: Set(folder_id),
filename: Set(filename.to_string()),
storage_path: Set(relative_path.clone()),
thumbnail_path: Set(thumbnail_path.map(|p| p.to_string_lossy().replace('\\', "/"))),
content_type: Set(content_type.to_string()),
file_size: Set(file_data.len() as i64),
width: Set(width),
height: Set(height),
alt_text: Set(None),
is_public: Set(is_public),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let m = model.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "media_item.created", "media_item")
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(model_to_resp(m))
}
// ---------------------------------------------------------------------------
// 媒体文件修改
// ---------------------------------------------------------------------------
/// 更新媒体文件元数据(文件名/alt/公开状态/文件夹),带乐观锁
pub async fn update_media_item(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: UpdateMediaItemReq,
) -> HealthResult<MediaItemResp> {
let model = find_media_item(state, tenant_id, id).await?;
let next_ver =
check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?;
let mut active: media_item::ActiveModel = model.into();
if let Some(v) = req.filename {
active.filename = Set(v);
}
if let Some(v) = req.alt_text {
active.alt_text = Set(Some(v));
}
if let Some(v) = req.is_public {
active.is_public = Set(v);
}
if let Some(v) = req.folder_id {
active.folder_id = Set(Some(v));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "media_item.updated", "media_item")
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(model_to_resp(m))
}
/// 软删除媒体文件:标记 deleted_at + 级联停用关联 banner + 删除磁盘文件
pub async fn delete_media_item(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
version: i32,
) -> HealthResult<()> {
let model = find_media_item(state, tenant_id, id).await?;
let next_ver =
check_version(version, model.version).map_err(|_| HealthError::VersionMismatch)?;
// 软删除
let mut active: media_item::ActiveModel = model.clone().into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.update(&state.db).await?;
// 级联:关联的 banner 全部设为 inactive
banner::Entity::update_many()
.col_expr(banner::Column::Status, Expr::value("inactive"))
.col_expr(
banner::Column::Version,
Expr::col(banner::Column::Version).add(1),
)
.filter(banner::Column::MediaItemId.eq(id))
.filter(banner::Column::TenantId.eq(tenant_id))
.exec(&state.db)
.await?;
// 删除磁盘文件(忽略文件不存在的错误)
let storage = PathBuf::from(&model.storage_path);
if storage.exists() {
let _ = tokio::fs::remove_file(&storage).await;
}
if let Some(ref thumb) = model.thumbnail_path {
let thumb_path = PathBuf::from(thumb);
if thumb_path.exists() {
let _ = tokio::fs::remove_file(&thumb_path).await;
}
}
audit_service::record(
AuditLog::new(tenant_id, operator_id, "media_item.deleted", "media_item")
.with_resource_id(id),
&state.db,
)
.await;
Ok(())
}
/// 批量软删除媒体文件
pub async fn batch_delete(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: BatchDeleteReq,
) -> HealthResult<()> {
if req.ids.is_empty() {
return Ok(());
}
let now = Utc::now();
media_item::Entity::update_many()
.col_expr(media_item::Column::DeletedAt, Expr::value(Some(now)))
.col_expr(media_item::Column::UpdatedAt, Expr::value(now))
.col_expr(media_item::Column::UpdatedBy, Expr::value(operator_id))
.col_expr(
media_item::Column::Version,
Expr::col(media_item::Column::Version).add(1),
)
.filter(media_item::Column::Id.is_in(req.ids.clone()))
.filter(media_item::Column::TenantId.eq(tenant_id))
.filter(media_item::Column::DeletedAt.is_null())
.exec(&state.db)
.await?;
// 级联:停用关联 banner
banner::Entity::update_many()
.col_expr(banner::Column::Status, Expr::value("inactive"))
.col_expr(
banner::Column::Version,
Expr::col(banner::Column::Version).add(1),
)
.filter(banner::Column::MediaItemId.is_in(req.ids.clone()))
.filter(banner::Column::TenantId.eq(tenant_id))
.exec(&state.db)
.await?;
audit_service::record(
AuditLog::new(
tenant_id,
operator_id,
"media_item.batch_deleted",
"media_item",
),
&state.db,
)
.await;
Ok(())
}
/// 移动媒体文件到指定文件夹None 表示根目录)
pub async fn move_media(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: MoveMediaReq,
) -> HealthResult<MediaItemResp> {
let model = find_media_item(state, tenant_id, id).await?;
let next_ver =
check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?;
let mut active: media_item::ActiveModel = model.into();
active.folder_id = Set(req.folder_id);
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "media_item.moved", "media_item")
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(model_to_resp(m))
}
/// 裁剪图片并重新生成缩略图
pub async fn crop_media(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: CropReq,
) -> HealthResult<MediaItemResp> {
let model = find_media_item(state, tenant_id, id).await?;
let next_ver =
check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?;
// 裁剪图片
let src_path = PathBuf::from(&model.storage_path);
let cropped_id = Uuid::now_v7();
let ext = src_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png");
let cropped_relative = format!("uploads/{}/{}.{}", tenant_id, cropped_id, ext);
let cropped_path = PathBuf::from(&cropped_relative);
if let Some(parent) = cropped_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| HealthError::Validation(format!("创建目录失败: {}", e)))?;
}
crop_image(
&src_path,
&cropped_path,
req.x,
req.y,
req.width,
req.height,
)?;
// 更新尺寸
let (new_width, new_height) = read_image_dimensions(&cropped_path);
// 重新生成缩略图
let thumbnail_path = generate_thumbnail_path(&cropped_path);
if let Some(thumb_dst) = &thumbnail_path {
if let Err(e) = std::fs::create_dir_all(thumb_dst.parent().unwrap_or(Path::new("."))) {
tracing::warn!("创建缩略图目录失败: {}", e);
}
if let Err(e) = generate_thumbnail(&cropped_path, thumb_dst, 200) {
tracing::warn!("生成缩略图失败 (非致命): {}", e);
}
}
let mut active: media_item::ActiveModel = model.into();
active.storage_path = Set(cropped_relative);
active.thumbnail_path = Set(thumbnail_path.map(|p| p.to_string_lossy().replace('\\', "/")));
active.width = Set(new_width);
active.height = Set(new_height);
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "media_item.cropped", "media_item")
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(model_to_resp(m))
}
// ---------------------------------------------------------------------------
// 图片处理工具函数(同步)
// ---------------------------------------------------------------------------
/// 中心裁剪 + 缩放生成缩略图
pub fn generate_thumbnail(src: &Path, dst: &Path, size: u32) -> HealthResult<()> {
let mut img = image::open(src).map_err(|e| HealthError::Validation(e.to_string()))?;
let (w, h) = (img.width(), img.height());
let crop_size = w.min(h);
let x = (w - crop_size) / 2;
let y = (h - crop_size) / 2;
let thumb = img.crop(x, y, crop_size, crop_size).resize_exact(
size,
size,
image::imageops::FilterType::Lanczos3,
);
thumb
.save(dst)
.map_err(|e| HealthError::Validation(e.to_string()))?;
Ok(())
}
/// 按指定区域裁剪图片
pub fn crop_image(src: &Path, dst: &Path, x: u32, y: u32, w: u32, h: u32) -> HealthResult<()> {
let mut img = image::open(src).map_err(|e| HealthError::Validation(e.to_string()))?;
let cropped = img.crop(x, y, w, h);
cropped
.save(dst)
.map_err(|e| HealthError::Validation(e.to_string()))?;
Ok(())
}
// ---------------------------------------------------------------------------
// 文件夹管理
// ---------------------------------------------------------------------------
/// 获取文件夹树形结构(含每个文件夹的文件数量)
pub async fn list_folders(state: &HealthState, tenant_id: Uuid) -> HealthResult<Vec<FolderResp>> {
// 查询所有未删除文件夹
let folders = media_folder::Entity::find()
.filter(media_folder::Column::TenantId.eq(tenant_id))
.filter(media_folder::Column::DeletedAt.is_null())
.order_by_asc(media_folder::Column::SortOrder)
.order_by_asc(media_folder::Column::CreatedAt)
.all(&state.db)
.await?;
// 统计每个文件夹的文件数
let folder_ids: Vec<Uuid> = folders.iter().map(|f| f.id).collect();
let mut count_map: std::collections::HashMap<Uuid, i64> = std::collections::HashMap::new();
if !folder_ids.is_empty() {
// 逐文件夹计数SeaORM 没有 GROUP BY count 的便捷方法,用子查询方式)
for fid in &folder_ids {
let count = media_item::Entity::find()
.filter(media_item::Column::TenantId.eq(tenant_id))
.filter(media_item::Column::FolderId.eq(*fid))
.filter(media_item::Column::DeletedAt.is_null())
.count(&state.db)
.await?;
count_map.insert(*fid, count as i64);
}
}
// 构建扁平列表
let flat: Vec<FolderResp> = folders
.into_iter()
.map(|f| FolderResp {
id: f.id,
tenant_id: f.tenant_id,
name: f.name,
parent_id: f.parent_id,
sort_order: f.sort_order,
children: vec![],
item_count: count_map.get(&f.id).copied().unwrap_or(0),
created_at: f.created_at,
updated_at: f.updated_at,
version: f.version,
})
.collect();
// 组装树形结构
Ok(build_folder_tree(flat))
}
/// 创建文件夹
pub async fn create_folder(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateFolderReq,
) -> HealthResult<FolderResp> {
let id = Uuid::now_v7();
let now = Utc::now();
let model = media_folder::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(req.name),
parent_id: Set(req.parent_id),
sort_order: Set(req.sort_order),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let m = model.insert(&state.db).await?;
audit_service::record(
AuditLog::new(
tenant_id,
operator_id,
"media_folder.created",
"media_folder",
)
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(FolderResp {
id: m.id,
tenant_id: m.tenant_id,
name: m.name,
parent_id: m.parent_id,
sort_order: m.sort_order,
children: vec![],
item_count: 0,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
})
}
/// 更新文件夹信息(名称/父级/排序),带乐观锁
pub async fn update_folder(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: UpdateFolderReq,
) -> HealthResult<FolderResp> {
let model = find_media_folder(state, tenant_id, id).await?;
let next_ver =
check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?;
let mut active: media_folder::ActiveModel = model.into();
if let Some(v) = req.name {
active.name = Set(v);
}
if let Some(v) = req.parent_id {
active.parent_id = Set(Some(v));
}
if let Some(v) = req.sort_order {
active.sort_order = Set(v);
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(
tenant_id,
operator_id,
"media_folder.updated",
"media_folder",
)
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(FolderResp {
id: m.id,
tenant_id: m.tenant_id,
name: m.name,
parent_id: m.parent_id,
sort_order: m.sort_order,
children: vec![],
item_count: 0,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
})
}
/// 删除文件夹(仅当文件夹为空时允许删除)
pub async fn delete_folder(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
version: i32,
) -> HealthResult<()> {
let model = find_media_folder(state, tenant_id, id).await?;
let next_ver =
check_version(version, model.version).map_err(|_| HealthError::VersionMismatch)?;
// 检查是否包含文件
let item_count = media_item::Entity::find()
.filter(media_item::Column::TenantId.eq(tenant_id))
.filter(media_item::Column::FolderId.eq(id))
.filter(media_item::Column::DeletedAt.is_null())
.count(&state.db)
.await?;
if item_count > 0 {
return Err(HealthError::Validation(
"文件夹内仍有文件,无法删除".to_string(),
));
}
// 检查是否包含子文件夹
let child_count = media_folder::Entity::find()
.filter(media_folder::Column::TenantId.eq(tenant_id))
.filter(media_folder::Column::ParentId.eq(id))
.filter(media_folder::Column::DeletedAt.is_null())
.count(&state.db)
.await?;
if child_count > 0 {
return Err(HealthError::Validation(
"文件夹内仍有子文件夹,无法删除".to_string(),
));
}
// 软删除
let mut active: media_folder::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.update(&state.db).await?;
audit_service::record(
AuditLog::new(
tenant_id,
operator_id,
"media_folder.deleted",
"media_folder",
)
.with_resource_id(id),
&state.db,
)
.await;
Ok(())
}
// ---------------------------------------------------------------------------
// 内部辅助函数
// ---------------------------------------------------------------------------
/// 查找媒体文件(未删除)
async fn find_media_item(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
) -> HealthResult<media_item::Model> {
media_item::Entity::find()
.filter(media_item::Column::Id.eq(id))
.filter(media_item::Column::TenantId.eq(tenant_id))
.filter(media_item::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::MediaNotFound)
}
/// 查找文件夹(未删除)
async fn find_media_folder(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
) -> HealthResult<media_folder::Model> {
media_folder::Entity::find()
.filter(media_folder::Column::Id.eq(id))
.filter(media_folder::Column::TenantId.eq(tenant_id))
.filter(media_folder::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::MediaFolderNotFound)
}
/// 将实体模型转换为 DTO 响应
fn model_to_resp(m: media_item::Model) -> MediaItemResp {
MediaItemResp {
id: m.id,
tenant_id: m.tenant_id,
folder_id: m.folder_id,
filename: m.filename,
storage_path: m.storage_path,
thumbnail_path: m.thumbnail_path,
content_type: m.content_type,
file_size: m.file_size,
width: m.width,
height: m.height,
alt_text: m.alt_text,
is_public: m.is_public,
created_at: m.created_at,
updated_at: m.updated_at,
created_by: m.created_by,
updated_by: m.updated_by,
version: m.version,
}
}
/// 读取图片文件的宽高(非图片或读取失败返回 None
fn read_image_dimensions(path: &Path) -> (Option<i32>, Option<i32>) {
image::open(path)
.map(|img| (Some(img.width() as i32), Some(img.height() as i32)))
.unwrap_or((None, None))
}
/// 根据存储路径推导缩略图路径(同目录下加 _thumb 后缀)
fn generate_thumbnail_path(storage_path: &Path) -> Option<PathBuf> {
let stem = storage_path.file_stem()?.to_str()?;
let ext = storage_path.extension()?.to_str()?;
let parent = storage_path.parent()?;
Some(parent.join(format!("{}_thumb.{}", stem, ext)))
}
/// 将扁平文件夹列表组装成树形结构
fn build_folder_tree(flat: Vec<FolderResp>) -> Vec<FolderResp> {
use std::collections::HashMap;
// id -> (parent_id, index in flat)
let mut by_id: HashMap<Uuid, (Option<Uuid>, usize)> = HashMap::new();
for (i, f) in flat.iter().enumerate() {
by_id.insert(f.id, (f.parent_id, i));
}
// 收集根节点
let mut root_ids: Vec<Uuid> = Vec::new();
for f in &flat {
match f.parent_id {
None => root_ids.push(f.id),
Some(pid) if !by_id.contains_key(&pid) => root_ids.push(f.id),
_ => {}
}
}
// 递归构建
fn build_children(parent_id: Uuid, flat: &[FolderResp]) -> Vec<FolderResp> {
let mut children: Vec<FolderResp> = Vec::new();
for f in flat {
if f.parent_id == Some(parent_id) {
let mut node = f.clone();
node.children = build_children(f.id, flat);
children.push(node);
}
}
children.sort_by_key(|c| c.sort_order);
children
}
let mut result: Vec<FolderResp> = Vec::new();
for rid in root_ids {
if let Some(&(_, idx)) = by_id.get(&rid) {
let mut node = flat[idx].clone();
node.children = build_children(rid, &flat);
result.push(node);
}
}
result.sort_by_key(|c| c.sort_order);
result
}