节点(mp_canvas_nodes) + 关联(mp_canvas_node_rels) + 分镜数据(mp_canvas_segments) * * 画布为无限扩展画布,无固定宽高;节点无限扩展、无固定根节点,仅记录两两关联。 * 关联查询一律按节点ID查邻接关系(a=node OR b=node),不依赖树形结构, * 因此无根节点不影响关联数据的获取;数据量大时可使用 graph 的局部子图查询。 * * 节点类型说明: * - theme/subject/scene/prop/segment 属于"列表型"节点,可复用全局资产库或动漫分集中的数据 * - text/image/video/audio 属于"内容型"节点 * - 关联无方向、多对多,通过 (node_id_a, node_id_b) 且 a < b 归一化存储 */ class CanvasService { /** 节点类型白名单 */ const NODE_TYPES = ['theme', 'subject', 'scene', 'prop', 'segment', 'text', 'image', 'video', 'audio']; /** 关联来源类型白名单 */ const SOURCE_TYPES = ['none', 'product', 'episode']; /** 画布文档大小与图规模限制 */ const MAX_DOCUMENT_BYTES = 2097152; // 2MB const MAX_NODES = 2000; const MAX_EDGES = 5000; const MAX_TEXT_LENGTH = 50000; const MAX_URL_LENGTH = 2048; const MAX_TITLE_LENGTH = 100; /** * 画布摘要列表(分页,不返回完整图) * * @param array $data anime_id-动漫ID(可选) episode_id-剧集ID(可选) page-页码 page_size-每页数量 * @return array */ public function canvasList($data) { $uid = Site::getUid(); $cpid = Site::getCpid(); $anime_id = (int)getProp($data, 'anime_id', 0); $episode_id = (int)getProp($data, 'episode_id', 0); $title = getProp($data, 'title'); $page = max(1, (int)getProp($data, 'page', 1)); $page_size = min(100, max(1, (int)getProp($data, 'page_size', 20))); $query = DB::table('mp_canvases') ->where('uid', $uid) ->where('cpid', $cpid) ->where('is_deleted', 0) ->select('canvas_uuid as canvas_id', 'name as title', 'description', 'cover_url', 'anime_id', 'episode_id', 'episode_number', 'created_at', 'updated_at'); if ($anime_id) { $query->where('anime_id', $anime_id); } if ($episode_id) { $query->where('episode_id', $episode_id); } if ($title) { $query->where('name', 'like', "%$title%"); } $list = $query->orderByDesc('id')->paginate($page_size, ['*'], 'page', $page); $items = $list->map(function ($item) { $item = (array)$item; $item['created_at'] = transDate($item['created_at'], 'Y-m-d H:i:s'); $item['updated_at'] = transDate($item['updated_at'], 'Y-m-d H:i:s'); $item['scope'] = [ 'anime_id' => (int)$item['anime_id'], 'episode_id' => (int)$item['episode_id'], 'episode_number' => (int)$item['episode_number'] ]; unset($item['anime_id'], $item['episode_id'], $item['episode_number']); return $item; })->toArray(); return [ 'meta' => getMeta($list), 'list' => $items ]; } /** * 创建画布 * * 必填:name、document;canvas_id 可选(缺省自动生成);可选:anime_id、episode_id、episode_number * * @param array $data * @return array */ public function createCanvas($data) { $uid = Site::getUid(); $cpid = Site::getCpid(); $now = date('Y-m-d H:i:s'); $canvas_uuid = trim((string)getProp($data, 'canvas_id', '')); if ($canvas_uuid === '') { $canvas_uuid = $this->generateCanvasId(); } if (mb_strlen($canvas_uuid) > 64) { Utils::throwError('20003:canvas_id长度超出限制'); } if (DB::table('mp_canvases')->where('canvas_uuid', $canvas_uuid)->exists()) { Utils::throwError('20003:画布ID已存在'); } $name = trim((string)$this->docProp($data, 'name', '')); if ($name === '') { Utils::throwError('20003:请提供画布名称'); } $document = $this->documentForStorage($this->extractDocument($data)); if ($document === null) { Utils::throwError('20003:请提供document'); } $this->checkDocumentSize($document); $canvas_id = DB::table('mp_canvases')->insertGetId([ 'uid' => $uid, 'cpid' => $cpid, 'canvas_uuid' => $canvas_uuid, 'name' => $name, 'description' => (string)$this->docProp($data, 'description', ''), 'cover_url' => (string)$this->docProp($data, 'cover_url', ''), 'anime_id' => $this->scopeProp($data, 'anime_id'), 'episode_id' => $this->scopeProp($data, 'episode_id'), 'episode_number' => $this->scopeProp($data, 'episode_number'), 'document' => $document, 'is_deleted' => 0, 'created_at' => $now, 'updated_at' => $now ]); if (!$canvas_id) { Utils::throwError('20003:创建画布失败'); } return $this->documentResult($canvas_uuid, $name, $document); } /** * 保存画布完整文档 * * 必填:canvas_id、name、document;可选:anime_id、episode_id、episode_number(不传保留原值) * * @param array $data * @return array */ public function editCanvas($data) { $canvas_id = (string)getProp($data, 'canvas_id', ''); if ($canvas_id === '') { Utils::throwError('20003:请提供画布ID'); } $canvas = $this->checkCanvas($canvas_id); $name = trim((string)$this->docProp($data, 'name', '')); if ($name === '') { Utils::throwError('20003:请提供画布名称'); } $document = $this->documentForStorage($this->extractDocument($data)); if ($document === null) { Utils::throwError('20003:请提供document'); } $this->checkDocumentSize($document); $update = [ 'name' => $name, 'document' => $document, 'updated_at' => date('Y-m-d H:i:s') ]; foreach (['anime_id', 'episode_id', 'episode_number'] as $field) { $value = $this->scopeProp($data, $field, null); if ($value !== null) { $update[$field] = $value; } } DB::table('mp_canvases')->where('id', $canvas->id)->update($update); return $this->documentResult($this->canvasKey($canvas), $name, $document); } /** * 删除画布(软删除画布/节点/分镜数据,物理删除关联) * * @param array $data canvas_id-画布ID * @return array */ public function delCanvas($data) { $canvas_id = (string)getProp($data, 'canvas_id', ''); $canvas = $this->checkCanvas($canvas_id); $now = date('Y-m-d H:i:s'); DB::beginTransaction(); try { DB::table('mp_canvases')->where('id', $canvas->id)->update(['is_deleted' => 1, 'updated_at' => $now]); DB::table('mp_canvas_nodes')->where('canvas_id', $canvas->id)->update(['is_deleted' => 1, 'updated_at' => $now]); DB::table('mp_canvas_segments')->where('canvas_id', $canvas->id)->update(['is_deleted' => 1, 'updated_at' => $now]); DB::table('mp_canvas_node_rels')->where('canvas_id', $canvas->id)->delete(); DB::commit(); } catch (\Throwable $e) { DB::rollBack(); Utils::throwError('20003:删除画布失败'); } return ['success' => 1]; } /** * 打开指定画布,返回完整文档 * * @param array $data canvas_id-画布ID(字符串 canvas_uuid 或旧数字ID) * @return array */ public function canvasDetail($data) { $canvas_id = (string)getProp($data, 'canvas_id', ''); $canvas = $this->checkCanvas($canvas_id); $raw = (string)getProp($canvas, 'document', ''); if ($raw !== '') { return $this->documentResult($this->canvasKey($canvas), (string)$canvas->name, $raw); } // 旧画布无 document 时由节点/关联表兜底构建 return $this->documentResult($this->canvasKey($canvas), (string)$canvas->name, $this->buildLegacyDocument($canvas)); } /** * 保存节点(创建/更新) * * 通用字段直接写 mp_canvas_nodes;node_type=segment 时同步写 mp_canvas_segments。 * 分镜关键数据优先取 data.segment 子对象,缺省时回退到节点级字段。 * * @param array $data canvas_id-画布ID node_id-节点ID(更新时) node_type-节点类型 * name/content/text_prompt/pic_prompt/image_url/video_url/audio_url/ * thumbnail_url/duration/pos_x/pos_y/z_index/size_w/size_h * source_type/source_product_id/source_anime_id/source_episode_id/source_ref/ext * segment-分镜数据子对象(可选) * @return array */ public function saveNode($data) { $canvas_id = (int)getProp($data, 'canvas_id'); $node_id = (int)getProp($data, 'node_id', 0); $node_type = (string)getProp($data, 'node_type', ''); if (!in_array($node_type, self::NODE_TYPES, true)) { Utils::throwError('20003:无效的节点类型'); } $this->checkCanvas($canvas_id); $uid = Site::getUid(); $cpid = Site::getCpid(); $now = date('Y-m-d H:i:s'); $fields = [ 'name' => (string)getProp($data, 'name', ''), 'content' => (string)getProp($data, 'content', ''), 'text_prompt' => (string)getProp($data, 'text_prompt', ''), 'pic_prompt' => (string)getProp($data, 'pic_prompt', ''), 'image_url' => (string)getProp($data, 'image_url', ''), 'video_url' => (string)getProp($data, 'video_url', ''), 'audio_url' => (string)getProp($data, 'audio_url', ''), 'thumbnail_url' => (string)getProp($data, 'thumbnail_url', ''), 'duration' => $this->nullableInt(getProp($data, 'duration', null)), 'pos_x' => (float)getProp($data, 'pos_x', 0), 'pos_y' => (float)getProp($data, 'pos_y', 0), 'z_index' => (int)getProp($data, 'z_index', 0), 'size_w' => $this->nullableFloat(getProp($data, 'size_w', null)), 'size_h' => $this->nullableFloat(getProp($data, 'size_h', null)), 'source_type' => (string)getProp($data, 'source_type', 'none'), 'source_product_id' => $this->nullableInt(getProp($data, 'source_product_id', null)), 'source_anime_id' => $this->nullableInt(getProp($data, 'source_anime_id', null)), 'source_episode_id' => $this->nullableInt(getProp($data, 'source_episode_id', null)), 'source_ref' => (string)getProp($data, 'source_ref', ''), 'ext' => $this->jsonEncode(getProp($data, 'ext', null)) ]; if (!in_array($fields['source_type'], self::SOURCE_TYPES, true)) { $fields['source_type'] = 'none'; } DB::beginTransaction(); try { $real_type = $node_type; if ($node_id) { $exists = DB::table('mp_canvas_nodes') ->where('id', $node_id) ->where('canvas_id', $canvas_id) ->where('is_deleted', 0) ->first(); if (!$exists) { Utils::throwError('20003:节点不存在'); } // 更新时节点类型以库中为准,不允许变更 $real_type = $exists->node_type; $fields['updated_at'] = $now; DB::table('mp_canvas_nodes')->where('id', $node_id)->update($fields); } else { $fields['canvas_id'] = $canvas_id; $fields['uid'] = $uid; $fields['cpid'] = $cpid; $fields['node_type'] = $node_type; $fields['is_deleted'] = 0; $fields['created_at'] = $now; $fields['updated_at'] = $now; $node_id = DB::table('mp_canvas_nodes')->insertGetId($fields); if (!$node_id) { Utils::throwError('20003:保存节点失败'); } } // 分镜节点同步分镜数据表 if ($real_type === 'segment') { $this->syncSegment($canvas_id, $node_id, $data, $now); } DB::commit(); } catch (\Throwable $e) { DB::rollBack(); throw $e; } return ['node_id' => $node_id]; } /** * 节点详情(含分镜数据与当前关联) * * @param array $data canvas_id-画布ID node_id-节点ID * @return array */ public function nodeInfo($data) { $canvas_id = (int)getProp($data, 'canvas_id'); $node_id = (int)getProp($data, 'node_id'); $this->checkCanvas($canvas_id); $node = $this->getNode($canvas_id, $node_id); $node = $this->formatNode($node, $canvas_id); $node['rel_ids'] = $this->getNodeRelIds($canvas_id, $node_id); $node['related_nodes'] = $this->getRelatedNodes($canvas_id, $node_id); return $node; } /** * 获取节点的所有关联节点 * * @param array $data canvas_id-画布ID node_id-节点ID * @return array */ public function nodeRels($data) { $canvas_id = (int)getProp($data, 'canvas_id'); $node_id = (int)getProp($data, 'node_id'); $this->checkCanvas($canvas_id); $this->getNode($canvas_id, $node_id); return [ 'node_id' => $node_id, 'rel_ids' => $this->getNodeRelIds($canvas_id, $node_id), 'related_nodes' => $this->getRelatedNodes($canvas_id, $node_id) ]; } /** * 保存节点关联(整体替换:先清空该节点的所有关联,再写入新关联) * * 关联无方向、多对多;内部统一归一化为 (小id, 大id) 存储,配合唯一索引防止重复。 * * @param array $data canvas_id-画布ID node_id-节点ID related_ids-关联节点ID数组(可传逗号分隔字符串) * @return array */ public function saveRels($data) { $canvas_id = (int)getProp($data, 'canvas_id'); $node_id = (int)getProp($data, 'node_id'); $this->checkCanvas($canvas_id); $this->getNode($canvas_id, $node_id); $related_ids = getProp($data, 'related_ids', []); if (!is_array($related_ids)) { $related_ids = $related_ids === '' ? [] : explode(',', (string)$related_ids); } $related_ids = array_values(array_unique(array_filter(array_map('intval', $related_ids)))); // 去除自身关联 $related_ids = array_values(array_diff($related_ids, [$node_id])); $pairs = []; if ($related_ids) { // 只允许关联同一画布下未删除的节点 $valid_ids = DB::table('mp_canvas_nodes') ->where('canvas_id', $canvas_id) ->whereIn('id', $related_ids) ->where('is_deleted', 0) ->pluck('id') ->all(); foreach ($valid_ids as $rid) { $a = (int)min($node_id, $rid); $b = (int)max($node_id, $rid); $pairs[$a . '_' . $b] = ['a' => $a, 'b' => $b]; } } $now = date('Y-m-d H:i:s'); DB::beginTransaction(); try { DB::table('mp_canvas_node_rels') ->where('canvas_id', $canvas_id) ->where(function ($query) use ($node_id) { $query->where('node_id_a', $node_id) ->orWhere('node_id_b', $node_id); }) ->delete(); if ($pairs) { $rows = []; foreach ($pairs as $pair) { $rows[] = [ 'canvas_id' => $canvas_id, 'node_id_a' => $pair['a'], 'node_id_b' => $pair['b'], 'created_at' => $now, 'updated_at' => $now ]; } DB::table('mp_canvas_node_rels')->insert($rows); } DB::commit(); } catch (\Throwable $e) { DB::rollBack(); Utils::throwError('20003:保存关联失败'); } return ['success' => 1, 'rel_count' => count($pairs)]; } /** * 删除节点(软删除节点/分镜数据,物理删除关联) * * @param array $data canvas_id-画布ID node_id-节点ID * @return array */ public function delNode($data) { $canvas_id = (int)getProp($data, 'canvas_id'); $node_id = (int)getProp($data, 'node_id'); $this->checkCanvas($canvas_id); $node = $this->getNode($canvas_id, $node_id); $now = date('Y-m-d H:i:s'); DB::beginTransaction(); try { DB::table('mp_canvas_nodes')->where('id', $node_id)->update(['is_deleted' => 1, 'updated_at' => $now]); DB::table('mp_canvas_node_rels') ->where('canvas_id', $canvas_id) ->where(function ($query) use ($node_id) { $query->where('node_id_a', $node_id) ->orWhere('node_id_b', $node_id); }) ->delete(); if ($node->node_type === 'segment') { DB::table('mp_canvas_segments') ->where('canvas_id', $canvas_id) ->where('node_id', $node_id) ->update(['is_deleted' => 1, 'updated_at' => $now]); } DB::commit(); } catch (\Throwable $e) { DB::rollBack(); Utils::throwError('20003:删除节点失败'); } return ['success' => 1]; } /** * 画布关系图(供前端渲染) * * 不传 node_id 时返回画布全部节点与关联;传 node_id 时返回以该节点为中心、 * 深度为 depth 的局部子图(按广度优先收集邻居;rels 只包含 BFS 展开过程中触及的边, * 即 depth=1 时仅返回中心节点的直接关联),适合大画布局部加载。 * 局部子图模式下,每个节点/每条关联附带 level 字段(节点为距中心的跳数, * 关联为 BFS 展开时首次被触及的轮次),另附 levels 摘要便于前端逐层加载与动画。 * * @param array $data canvas_id-画布ID node_id-中心节点ID(可选) depth-子图深度(可选,默认1) * @return array */ public function graph($data) { $canvas_id = (int)getProp($data, 'canvas_id'); $node_id = (int)getProp($data, 'node_id', 0); $depth = min(5, max(1, (int)getProp($data, 'depth', 1))); $this->checkCanvas($canvas_id); if ($node_id) { return $this->getSubGraph($canvas_id, $node_id, $depth); } return $this->getGraph($canvas_id); } /* ------------------------------------------------------------------ */ /* 私有方法 */ /* ------------------------------------------------------------------ */ /** * 校验画布归属并返回画布 * * @param string|int $canvas_id * @return object */ private function checkCanvas($canvas_id) { if ($canvas_id === '' || $canvas_id === null) { Utils::throwError('20003:请提供画布ID'); } $query = DB::table('mp_canvases') ->where('uid', Site::getUid()) ->where('cpid', Site::getCpid()) ->where('is_deleted', 0); if (is_numeric($canvas_id)) { $query->where('id', (int)$canvas_id); } else { $query->where('canvas_uuid', (string)$canvas_id); } $canvas = $query->first(); if (!$canvas) { Utils::throwError('20003:画布不存在或无权访问'); } return $canvas; } /** * 画布对外资源 ID:优先字符串 canvas_uuid,旧数据兜底数字 id * * @param object $canvas * @return string */ private function canvasKey($canvas) { $uuid = (string)getProp($canvas, 'canvas_uuid', ''); return $uuid !== '' ? $uuid : (string)(int)getProp($canvas, 'id', 0); } /** * 生成前端可用的稳定画布 ID * * @return string */ private function generateCanvasId() { return 'canvas-' . \Illuminate\Support\Str::uuid()->toString(); } /** * 提取前端传入的 document(必填字段) * * @param array $data * @return mixed */ private function extractDocument($data) { if (!is_array($data)) { Utils::throwError('20003:请求体格式不正确'); } if (!array_key_exists('document', $data)) { Utils::throwError('20003:请提供document'); } return $data['document']; } /** * 规范化 document 用于数据库存储:JSON 对象转字符串;JSON 字符串原样保存;空值存 null * * @param mixed $document * @return string|null */ private function documentForStorage($document) { if ($document === null || $document === '') { return null; } if (is_array($document) || is_object($document)) { $json = json_encode($document, JSON_UNESCAPED_UNICODE); if ($json === false) { Utils::throwError('20003:document格式不正确'); } return $json; } if (is_string($document)) { return $document; } Utils::throwError('20003:document格式不正确'); } /** * 存储的 document 返回给前端:JSON 字符串解码为对象;非 JSON 字符串原样返回 * * @param mixed $document * @return mixed */ private function documentForResponse($document) { if ($document === null || $document === '') { return null; } if (is_array($document) || is_object($document)) { return $document; } $raw = (string)$document; $doc = json_decode($raw, true); if ($doc === null && trim($raw) !== 'null') { return $raw; } return $doc; } /** * 组装画布文档相关返回结构:document 原样返回,canvas_id 由服务端维护 * * @param string $canvas_id * @param mixed $document * @return array */ private function documentResult($canvas_id, $name, $document) { return [ 'canvas_id' => (string)$canvas_id, 'name' => (string)$name, 'document' => $this->documentForResponse($document) ]; } /** * 校验 document 存储大小(仅整体大小限制,不解析内部结构) * * @param string|null $document * @return void */ private function checkDocumentSize($document) { if ($document !== null && strlen($document) > self::MAX_DOCUMENT_BYTES) { Utils::throwError('20003:画布文档大小超出限制'); } } /** * 读取请求中 document 相关字段(兼容 document 放在顶层或 document 字段内) * * @param array $data * @param string $key * @param mixed $default * @return mixed */ private function docProp($data, $key, $default = '') { if (array_key_exists($key, $data)) { return $data[$key]; } $doc = getProp($data, 'document', null); if (is_array($doc) && array_key_exists($key, $doc)) { return $doc[$key]; } return $default; } /** * 读取画布元数据列(anime_id/episode_id/episode_number), * 兼容顶层字段、document 顶层字段、document.scope 三种位置;不影响 document 原样保存 * * @param array $data * @param string $key * @param mixed $default * @return int|null */ private function scopeProp($data, $key, $default = 0) { if (array_key_exists($key, $data)) { return (int)$data[$key]; } $doc = getProp($data, 'document', null); if (is_array($doc)) { if (array_key_exists($key, $doc)) { return (int)$doc[$key]; } $scope = getProp($doc, 'scope', []); if (is_array($scope) && array_key_exists($key, $scope)) { return (int)$scope[$key]; } } $scope = getProp($data, 'scope', null); if (is_array($scope) && array_key_exists($key, $scope)) { return (int)$scope[$key]; } return $default; } /** * 从画布行组装 scope * * @param object $canvas * @return array */ private function scopeFromRow($canvas) { return [ 'anime_id' => (int)getProp($canvas, 'anime_id', 0), 'episode_id' => (int)getProp($canvas, 'episode_id', 0), 'episode_number' => (int)getProp($canvas, 'episode_number', 0) ]; } /** * 旧画布文档兜底:由节点/关联表生成 graph * * @param object $canvas * @return array */ private function buildLegacyDocument($canvas) { $graph = $this->getGraph((int)getProp($canvas, 'id', 0)); $nodes = []; foreach ($graph['nodes'] as $node) { $node = (array)$node; if (isset($node['id'])) { $node['id'] = (string)$node['id']; } $nodes[] = $node; } $edges = []; foreach ($graph['rels'] as $rel) { $edges[] = [ 'id' => (string)$rel['id'], 'source' => (string)$rel['node_id_a'], 'target' => (string)$rel['node_id_b'] ]; } return [ 'canvas_id' => $this->canvasKey($canvas), 'scope' => $this->scopeFromRow($canvas), 'title' => (string)getProp($canvas, 'name', '未命名画布'), 'graph' => [ 'viewport' => ['x' => 0, 'y' => 0, 'zoom' => 1], 'nodes' => $nodes, 'edges' => $edges ], 'preferences' => [ 'minimap_visible' => true, 'snap_to_grid_enabled' => false ] ]; } /** * 校验节点归属并返回节点 * * @param int $canvas_id * @param int $node_id * @return object */ private function getNode($canvas_id, $node_id) { if (!$node_id) { Utils::throwError('20003:请提供节点ID'); } $node = DB::table('mp_canvas_nodes') ->where('id', $node_id) ->where('canvas_id', $canvas_id) ->where('is_deleted', 0) ->first(); if (!$node) { Utils::throwError('20003:节点不存在'); } return $node; } /** * 同步分镜数据表(创建或更新) * * @param int $canvas_id * @param int $node_id * @param array $data * @param string $now * @return void */ private function syncSegment($canvas_id, $node_id, $data, $now) { $seg = getProp($data, 'segment'); $seg = is_array($seg) ? $seg : []; $seg_fields = [ 'segment_number' => (int)getProp($seg, 'segment_number', 1), 'name' => (string)getProp($seg, 'name', getProp($data, 'name', '')), 'text_prompt' => (string)getProp($seg, 'text_prompt', getProp($data, 'text_prompt', '')), 'pic_prompt' => (string)getProp($seg, 'pic_prompt', getProp($data, 'pic_prompt', '')), 'image_url' => (string)getProp($seg, 'image_url', getProp($data, 'image_url', '')), 'video_url' => (string)getProp($seg, 'video_url', getProp($data, 'video_url', '')), 'audio_url' => (string)getProp($seg, 'audio_url', getProp($data, 'audio_url', '')), 'duration' => $this->nullableInt(getProp($seg, 'duration', getProp($data, 'duration', null))), 'status' => (string)getProp($seg, 'status', 'draft'), 'ext' => $this->jsonEncode(getProp($seg, 'ext', null)) ]; $exists = DB::table('mp_canvas_segments') ->where('canvas_id', $canvas_id) ->where('node_id', $node_id) ->where('is_deleted', 0) ->first(); if ($exists) { $seg_fields['updated_at'] = $now; DB::table('mp_canvas_segments')->where('id', $exists->id)->update($seg_fields); } else { $seg_fields['canvas_id'] = $canvas_id; $seg_fields['node_id'] = $node_id; $seg_fields['uid'] = Site::getUid(); $seg_fields['cpid'] = Site::getCpid(); $seg_fields['is_deleted'] = 0; $seg_fields['created_at'] = $now; $seg_fields['updated_at'] = $now; DB::table('mp_canvas_segments')->insert($seg_fields); } } /** * 获取以指定节点为中心的局部子图(广度优先,最多 depth 层) * * 节点集合为距中心不超过 depth 跳的所有节点; * 关系集合为 BFS 展开过程中触及的边(至少一端距中心不超过 depth-1 跳), * 因此 depth=1 时只返回中心节点的直接关联,不会返回"邻居之间的边"。 * 返回的节点带 level(距中心跳数,中心为 0),关联带 level(首次被触及的轮次), * 并提供 levels 摘要 [{level, node_ids, rel_ids}] 供前端逐层渲染。 * * @param int $canvas_id * @param int $node_id * @param int $depth * @return array */ private function getSubGraph($canvas_id, $node_id, $depth) { $this->getNode($canvas_id, $node_id); // 广度优先收集节点(节点只入队一次,避免环导致死循环) $collectedMap = [$node_id => 0]; $collected = [$node_id]; $frontier = [$node_id]; $edgeMap = []; for ($i = 0; $i < $depth; $i++) { $level = $i + 1; $rels = DB::table('mp_canvas_node_rels') ->where('canvas_id', $canvas_id) ->where(function ($query) use ($frontier) { $query->whereIn('node_id_a', $frontier) ->orWhereIn('node_id_b', $frontier); }) ->get(); $next = []; foreach ($rels as $rel) { $a = (int)$rel->node_id_a; $b = (int)$rel->node_id_b; // 只记录 BFS 展开过程中触及的边(去重),不返回"邻居之间的边" if (!isset($edgeMap[$a . '_' . $b])) { $edgeMap[$a . '_' . $b] = [ 'id' => (int)$rel->id, 'node_id_a' => $a, 'node_id_b' => $b, 'level' => $level ]; } foreach ([$a, $b] as $nid) { if (!isset($collectedMap[$nid])) { $collectedMap[$nid] = $level; $collected[] = $nid; $next[] = $nid; } } } if (!$next) { break; } $frontier = $next; } $nodes = DB::table('mp_canvas_nodes') ->where('canvas_id', $canvas_id) ->whereIn('id', $collected) ->where('is_deleted', 0) ->orderBy('z_index') ->orderBy('id') ->get() ->map(function ($node) use ($canvas_id, $collectedMap) { $node = $this->formatNode($node, $canvas_id); $node['level'] = $collectedMap[$node['id']]; return $node; }) ->toArray(); $rels = array_values($edgeMap); usort($rels, function ($x, $y) { return $x['id'] - $y['id']; }); // 层级摘要:每层包含的节点ID与关联ID $levelNodes = []; $levelRels = []; foreach ($collected as $nid) { $levelNodes[$collectedMap[$nid]][] = $nid; } foreach ($rels as $rel) { $levelRels[$rel['level']][] = $rel['id']; } $levels = []; for ($lv = 0; $lv <= $depth; $lv++) { if (isset($levelNodes[$lv]) || isset($levelRels[$lv])) { $levels[] = [ 'level' => $lv, 'node_ids' => isset($levelNodes[$lv]) ? $levelNodes[$lv] : [], 'rel_ids' => isset($levelRels[$lv]) ? $levelRels[$lv] : [] ]; } } return [ 'center_node_id' => $node_id, 'depth' => $depth, 'levels' => $levels, 'nodes' => $nodes, 'rels' => $rels ]; } /** * 获取画布全量节点与关联 * * @param int $canvas_id * @return array */ private function getGraph($canvas_id) { $nodes = DB::table('mp_canvas_nodes') ->where('canvas_id', $canvas_id) ->where('is_deleted', 0) ->orderBy('z_index') ->orderBy('id') ->get() ->map(function ($node) use ($canvas_id) { return $this->formatNode($node, $canvas_id); }) ->toArray(); $rels = DB::table('mp_canvas_node_rels') ->where('canvas_id', $canvas_id) ->orderBy('id') ->get() ->map(function ($rel) { return [ 'id' => (int)$rel->id, 'node_id_a' => (int)$rel->node_id_a, 'node_id_b' => (int)$rel->node_id_b ]; }) ->toArray(); return ['nodes' => $nodes, 'rels' => $rels]; } /** * 格式化节点输出(分镜节点合并分镜数据表字段) * * @param object $node * @param int $canvas_id * @return array */ private function formatNode($node, $canvas_id) { $node = (array)$node; $node['ext'] = $node['ext'] ? json_decode($node['ext'], true) : null; if ($node['node_type'] === 'segment') { $segment = DB::table('mp_canvas_segments') ->where('canvas_id', $canvas_id) ->where('node_id', $node['id']) ->where('is_deleted', 0) ->first(); if ($segment) { $segment = (array)$segment; // 分镜数据表为准,覆盖节点级同名关键字段 foreach (['name', 'text_prompt', 'pic_prompt', 'image_url', 'video_url', 'audio_url', 'duration', 'ext'] as $field) { $node[$field] = $segment[$field]; } $node['segment_number'] = $segment['segment_number']; $node['status'] = $segment['status']; } else { $node['segment_number'] = null; $node['status'] = null; } } $node['id'] = (int)$node['id']; $node['canvas_id'] = (int)$node['canvas_id']; $node['pos_x'] = (float)$node['pos_x']; $node['pos_y'] = (float)$node['pos_y']; $node['z_index'] = (int)$node['z_index']; $node['duration'] = $node['duration'] !== null ? (int)$node['duration'] : null; $node['source_product_id'] = $node['source_product_id'] !== null ? (int)$node['source_product_id'] : null; $node['source_anime_id'] = $node['source_anime_id'] !== null ? (int)$node['source_anime_id'] : null; $node['source_episode_id'] = $node['source_episode_id'] !== null ? (int)$node['source_episode_id'] : null; return $node; } /** * 获取节点所有关联节点ID * * @param int $canvas_id * @param int $node_id * @return array */ private function getNodeRelIds($canvas_id, $node_id) { $rels = DB::table('mp_canvas_node_rels') ->where('canvas_id', $canvas_id) ->where(function ($query) use ($node_id) { $query->where('node_id_a', $node_id) ->orWhere('node_id_b', $node_id); }) ->get(); $ids = []; foreach ($rels as $rel) { $ids[] = (int)($rel->node_id_a == $node_id ? $rel->node_id_b : $rel->node_id_a); } return $ids; } /** * 获取节点的关联节点完整信息 * * @param int $canvas_id * @param int $node_id * @return array */ private function getRelatedNodes($canvas_id, $node_id) { $ids = $this->getNodeRelIds($canvas_id, $node_id); if (!$ids) { return []; } return DB::table('mp_canvas_nodes') ->where('canvas_id', $canvas_id) ->whereIn('id', $ids) ->where('is_deleted', 0) ->orderBy('id') ->get() ->map(function ($node) use ($canvas_id) { return $this->formatNode($node, $canvas_id); }) ->toArray(); } /** * 可空整型 * * @param mixed $value * @return int|null */ private function nullableInt($value) { return ($value === null || $value === '') ? null : (int)$value; } /** * 可空浮点 * * @param mixed $value * @return float|null */ private function nullableFloat($value) { return ($value === null || $value === '') ? null : (float)$value; } /** * JSON 编码(已是 JSON 字符串则原样保留) * * @param mixed $value * @return string|null */ private function jsonEncode($value) { if ($value === null || $value === '') { return null; } if (is_string($value)) { return json_decode($value, true) !== null ? $value : json_encode($value, JSON_UNESCAPED_UNICODE); } return json_encode($value, JSON_UNESCAPED_UNICODE); } }