CanvasService.php 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  1. <?php
  2. namespace App\Services\Canvas;
  3. use App\Facade\Site;
  4. use App\Libs\Utils;
  5. use Illuminate\Support\Facades\DB;
  6. /**
  7. * 画布模式服务
  8. *
  9. * 核心模型:
  10. * 画布(mp_canvases) -> 节点(mp_canvas_nodes) + 关联(mp_canvas_node_rels) + 分镜数据(mp_canvas_segments)
  11. *
  12. * 画布为无限扩展画布,无固定宽高;节点无限扩展、无固定根节点,仅记录两两关联。
  13. * 关联查询一律按节点ID查邻接关系(a=node OR b=node),不依赖树形结构,
  14. * 因此无根节点不影响关联数据的获取;数据量大时可使用 graph 的局部子图查询。
  15. *
  16. * 节点类型说明:
  17. * - theme/subject/scene/prop/segment 属于"列表型"节点,可复用全局资产库或动漫分集中的数据
  18. * - text/image/video/audio 属于"内容型"节点
  19. * - 关联无方向、多对多,通过 (node_id_a, node_id_b) 且 a < b 归一化存储
  20. */
  21. class CanvasService
  22. {
  23. /** 节点类型白名单 */
  24. const NODE_TYPES = ['theme', 'subject', 'scene', 'prop', 'segment', 'text', 'image', 'video', 'audio'];
  25. /** 关联来源类型白名单 */
  26. const SOURCE_TYPES = ['none', 'product', 'episode'];
  27. /**
  28. * 画布列表(分页)
  29. *
  30. * @param array $data keyword-关键字搜索 page-页码 page_size-每页数量
  31. * @return array
  32. */
  33. public function canvasList($data)
  34. {
  35. $uid = Site::getUid();
  36. $cpid = Site::getCpid();
  37. $keyword = trim((string)getProp($data, 'name', ''));
  38. $canvas_id = getProp($data, 'canvas_id');
  39. $page = max(1, (int)getProp($data, 'page', 1));
  40. $page_size = min(100, max(1, (int)getProp($data, 'page_size', 20)));
  41. $query = DB::table('mp_canvases')
  42. ->where('uid', $uid)
  43. ->where('cpid', $cpid)
  44. ->where('is_deleted', 0)
  45. ->select('id as canvas_id', 'name', 'description', 'cover_url', 'created_at',
  46. DB::raw('(select count(id) from mp_canvas_nodes where canvas_id = mp_canvases.id and is_deleted = 0) as node_count'),
  47. DB::raw('(select count(id) from mp_canvas_segments where canvas_id = mp_canvases.id and is_deleted = 0) as segment_count'));
  48. if ($canvas_id) {
  49. $query->where('id', $canvas_id);
  50. }
  51. if ($keyword !== '') {
  52. $query->where('name', 'like', "%{$keyword}%");
  53. }
  54. $list = $query->orderByDesc('id')->paginate($page_size, ['*'], 'page', $page);
  55. $items = $list->map(function ($item) {
  56. $item = (array)$item;
  57. $item['created_at'] = transDate($item['created_at'], 'Y-m-d H:i:s');
  58. // $item['updated_at'] = transDate($item['updated_at'], 'Y-m-d H:i:s');
  59. return $item;
  60. })->toArray();
  61. return [
  62. 'meta' => getMeta($list),
  63. 'list' => $items
  64. ];
  65. }
  66. /**
  67. * 创建画布
  68. *
  69. * @param array $data name-画布名称 description-描述 cover_url-封面
  70. * @return array
  71. */
  72. public function createCanvas($data)
  73. {
  74. $name = trim((string)getProp($data, 'name', ''));
  75. if ($name === '') {
  76. Utils::throwError('20003:请提供画布名称');
  77. }
  78. $uid = Site::getUid();
  79. $cpid = Site::getCpid();
  80. $now = date('Y-m-d H:i:s');
  81. $canvas_id = DB::table('mp_canvases')->insertGetId([
  82. 'uid' => $uid,
  83. 'cpid' => $cpid,
  84. 'name' => $name,
  85. 'description' => (string)getProp($data, 'description', ''),
  86. 'cover_url' => (string)getProp($data, 'cover_url', ''),
  87. 'is_deleted' => 0,
  88. 'created_at' => $now,
  89. 'updated_at' => $now
  90. ]);
  91. if (!$canvas_id) {
  92. Utils::throwError('20003:创建画布失败');
  93. }
  94. return ['canvas_id' => $canvas_id];
  95. }
  96. /**
  97. * 编辑画布(只更新传入的字段)
  98. *
  99. * @param array $data canvas_id-画布ID 其余可选字段
  100. * @return array
  101. */
  102. public function editCanvas($data)
  103. {
  104. $canvas_id = (int)getProp($data, 'canvas_id');
  105. $this->checkCanvas($canvas_id);
  106. $update = ['updated_at' => date('Y-m-d H:i:s')];
  107. $editable = ['name', 'description', 'cover_url'];
  108. foreach ($editable as $field) {
  109. if (array_key_exists($field, $data)) {
  110. $update[$field] = (string)$data[$field];
  111. }
  112. }
  113. DB::table('mp_canvases')->where('id', $canvas_id)->update($update);
  114. return ['success' => 1];
  115. }
  116. /**
  117. * 删除画布(软删除画布/节点/分镜数据,物理删除关联)
  118. *
  119. * @param array $data canvas_id-画布ID
  120. * @return array
  121. */
  122. public function delCanvas($data)
  123. {
  124. $canvas_id = (int)getProp($data, 'canvas_id');
  125. $this->checkCanvas($canvas_id);
  126. $now = date('Y-m-d H:i:s');
  127. DB::beginTransaction();
  128. try {
  129. DB::table('mp_canvases')->where('id', $canvas_id)->update(['is_deleted' => 1, 'updated_at' => $now]);
  130. DB::table('mp_canvas_nodes')->where('canvas_id', $canvas_id)->update(['is_deleted' => 1, 'updated_at' => $now]);
  131. DB::table('mp_canvas_segments')->where('canvas_id', $canvas_id)->update(['is_deleted' => 1, 'updated_at' => $now]);
  132. DB::table('mp_canvas_node_rels')->where('canvas_id', $canvas_id)->delete();
  133. DB::commit();
  134. } catch (\Throwable $e) {
  135. DB::rollBack();
  136. Utils::throwError('20003:删除画布失败');
  137. }
  138. return ['success' => 1];
  139. }
  140. /**
  141. * 画布详情(画布信息 + 全部节点 + 全部关联)
  142. *
  143. * @param array $data canvas_id-画布ID
  144. * @return array
  145. */
  146. public function canvasDetail($data)
  147. {
  148. $canvas_id = (int)getProp($data, 'canvas_id');
  149. $canvas = $this->checkCanvas($canvas_id);
  150. $canvas = (array)$canvas;
  151. $canvas['created_at'] = transDate($canvas['created_at'], 'Y-m-d H:i:s');
  152. $canvas['updated_at'] = transDate($canvas['updated_at'], 'Y-m-d H:i:s');
  153. return array_merge($canvas, $this->getGraph($canvas_id));
  154. }
  155. /**
  156. * 保存节点(创建/更新)
  157. *
  158. * 通用字段直接写 mp_canvas_nodes;node_type=segment 时同步写 mp_canvas_segments。
  159. * 分镜关键数据优先取 data.segment 子对象,缺省时回退到节点级字段。
  160. *
  161. * @param array $data canvas_id-画布ID node_id-节点ID(更新时) node_type-节点类型
  162. * name/content/text_prompt/pic_prompt/image_url/video_url/audio_url/
  163. * thumbnail_url/duration/pos_x/pos_y/z_index/size_w/size_h
  164. * source_type/source_product_id/source_anime_id/source_episode_id/source_ref/ext
  165. * segment-分镜数据子对象(可选)
  166. * @return array
  167. */
  168. public function saveNode($data)
  169. {
  170. $canvas_id = (int)getProp($data, 'canvas_id');
  171. $node_id = (int)getProp($data, 'node_id', 0);
  172. $node_type = (string)getProp($data, 'node_type', '');
  173. if (!in_array($node_type, self::NODE_TYPES, true)) {
  174. Utils::throwError('20003:无效的节点类型');
  175. }
  176. $this->checkCanvas($canvas_id);
  177. $uid = Site::getUid();
  178. $cpid = Site::getCpid();
  179. $now = date('Y-m-d H:i:s');
  180. $fields = [
  181. 'name' => (string)getProp($data, 'name', ''),
  182. 'content' => (string)getProp($data, 'content', ''),
  183. 'text_prompt' => (string)getProp($data, 'text_prompt', ''),
  184. 'pic_prompt' => (string)getProp($data, 'pic_prompt', ''),
  185. 'image_url' => (string)getProp($data, 'image_url', ''),
  186. 'video_url' => (string)getProp($data, 'video_url', ''),
  187. 'audio_url' => (string)getProp($data, 'audio_url', ''),
  188. 'thumbnail_url' => (string)getProp($data, 'thumbnail_url', ''),
  189. 'duration' => $this->nullableInt(getProp($data, 'duration', null)),
  190. 'pos_x' => (float)getProp($data, 'pos_x', 0),
  191. 'pos_y' => (float)getProp($data, 'pos_y', 0),
  192. 'z_index' => (int)getProp($data, 'z_index', 0),
  193. 'size_w' => $this->nullableFloat(getProp($data, 'size_w', null)),
  194. 'size_h' => $this->nullableFloat(getProp($data, 'size_h', null)),
  195. 'source_type' => (string)getProp($data, 'source_type', 'none'),
  196. 'source_product_id' => $this->nullableInt(getProp($data, 'source_product_id', null)),
  197. 'source_anime_id' => $this->nullableInt(getProp($data, 'source_anime_id', null)),
  198. 'source_episode_id' => $this->nullableInt(getProp($data, 'source_episode_id', null)),
  199. 'source_ref' => (string)getProp($data, 'source_ref', ''),
  200. 'ext' => $this->jsonEncode(getProp($data, 'ext', null))
  201. ];
  202. if (!in_array($fields['source_type'], self::SOURCE_TYPES, true)) {
  203. $fields['source_type'] = 'none';
  204. }
  205. DB::beginTransaction();
  206. try {
  207. $real_type = $node_type;
  208. if ($node_id) {
  209. $exists = DB::table('mp_canvas_nodes')
  210. ->where('id', $node_id)
  211. ->where('canvas_id', $canvas_id)
  212. ->where('is_deleted', 0)
  213. ->first();
  214. if (!$exists) {
  215. Utils::throwError('20003:节点不存在');
  216. }
  217. // 更新时节点类型以库中为准,不允许变更
  218. $real_type = $exists->node_type;
  219. $fields['updated_at'] = $now;
  220. DB::table('mp_canvas_nodes')->where('id', $node_id)->update($fields);
  221. } else {
  222. $fields['canvas_id'] = $canvas_id;
  223. $fields['uid'] = $uid;
  224. $fields['cpid'] = $cpid;
  225. $fields['node_type'] = $node_type;
  226. $fields['is_deleted'] = 0;
  227. $fields['created_at'] = $now;
  228. $fields['updated_at'] = $now;
  229. $node_id = DB::table('mp_canvas_nodes')->insertGetId($fields);
  230. if (!$node_id) {
  231. Utils::throwError('20003:保存节点失败');
  232. }
  233. }
  234. // 分镜节点同步分镜数据表
  235. if ($real_type === 'segment') {
  236. $this->syncSegment($canvas_id, $node_id, $data, $now);
  237. }
  238. DB::commit();
  239. } catch (\Throwable $e) {
  240. DB::rollBack();
  241. throw $e;
  242. }
  243. return ['node_id' => $node_id];
  244. }
  245. /**
  246. * 节点详情(含分镜数据与当前关联)
  247. *
  248. * @param array $data canvas_id-画布ID node_id-节点ID
  249. * @return array
  250. */
  251. public function nodeInfo($data)
  252. {
  253. $canvas_id = (int)getProp($data, 'canvas_id');
  254. $node_id = (int)getProp($data, 'node_id');
  255. $this->checkCanvas($canvas_id);
  256. $node = $this->getNode($canvas_id, $node_id);
  257. $node = $this->formatNode($node, $canvas_id);
  258. $node['rel_ids'] = $this->getNodeRelIds($canvas_id, $node_id);
  259. $node['related_nodes'] = $this->getRelatedNodes($canvas_id, $node_id);
  260. return $node;
  261. }
  262. /**
  263. * 获取节点的所有关联节点
  264. *
  265. * @param array $data canvas_id-画布ID node_id-节点ID
  266. * @return array
  267. */
  268. public function nodeRels($data)
  269. {
  270. $canvas_id = (int)getProp($data, 'canvas_id');
  271. $node_id = (int)getProp($data, 'node_id');
  272. $this->checkCanvas($canvas_id);
  273. $this->getNode($canvas_id, $node_id);
  274. return [
  275. 'node_id' => $node_id,
  276. 'rel_ids' => $this->getNodeRelIds($canvas_id, $node_id),
  277. 'related_nodes' => $this->getRelatedNodes($canvas_id, $node_id)
  278. ];
  279. }
  280. /**
  281. * 保存节点关联(整体替换:先清空该节点的所有关联,再写入新关联)
  282. *
  283. * 关联无方向、多对多;内部统一归一化为 (小id, 大id) 存储,配合唯一索引防止重复。
  284. *
  285. * @param array $data canvas_id-画布ID node_id-节点ID related_ids-关联节点ID数组(可传逗号分隔字符串)
  286. * @return array
  287. */
  288. public function saveRels($data)
  289. {
  290. $canvas_id = (int)getProp($data, 'canvas_id');
  291. $node_id = (int)getProp($data, 'node_id');
  292. $this->checkCanvas($canvas_id);
  293. $this->getNode($canvas_id, $node_id);
  294. $related_ids = getProp($data, 'related_ids', []);
  295. if (!is_array($related_ids)) {
  296. $related_ids = $related_ids === '' ? [] : explode(',', (string)$related_ids);
  297. }
  298. $related_ids = array_values(array_unique(array_filter(array_map('intval', $related_ids))));
  299. // 去除自身关联
  300. $related_ids = array_values(array_diff($related_ids, [$node_id]));
  301. $pairs = [];
  302. if ($related_ids) {
  303. // 只允许关联同一画布下未删除的节点
  304. $valid_ids = DB::table('mp_canvas_nodes')
  305. ->where('canvas_id', $canvas_id)
  306. ->whereIn('id', $related_ids)
  307. ->where('is_deleted', 0)
  308. ->pluck('id')
  309. ->all();
  310. foreach ($valid_ids as $rid) {
  311. $a = (int)min($node_id, $rid);
  312. $b = (int)max($node_id, $rid);
  313. $pairs[$a . '_' . $b] = ['a' => $a, 'b' => $b];
  314. }
  315. }
  316. $now = date('Y-m-d H:i:s');
  317. DB::beginTransaction();
  318. try {
  319. DB::table('mp_canvas_node_rels')
  320. ->where('canvas_id', $canvas_id)
  321. ->where(function ($query) use ($node_id) {
  322. $query->where('node_id_a', $node_id)
  323. ->orWhere('node_id_b', $node_id);
  324. })
  325. ->delete();
  326. if ($pairs) {
  327. $rows = [];
  328. foreach ($pairs as $pair) {
  329. $rows[] = [
  330. 'canvas_id' => $canvas_id,
  331. 'node_id_a' => $pair['a'],
  332. 'node_id_b' => $pair['b'],
  333. 'created_at' => $now,
  334. 'updated_at' => $now
  335. ];
  336. }
  337. DB::table('mp_canvas_node_rels')->insert($rows);
  338. }
  339. DB::commit();
  340. } catch (\Throwable $e) {
  341. DB::rollBack();
  342. Utils::throwError('20003:保存关联失败');
  343. }
  344. return ['success' => 1, 'rel_count' => count($pairs)];
  345. }
  346. /**
  347. * 删除节点(软删除节点/分镜数据,物理删除关联)
  348. *
  349. * @param array $data canvas_id-画布ID node_id-节点ID
  350. * @return array
  351. */
  352. public function delNode($data)
  353. {
  354. $canvas_id = (int)getProp($data, 'canvas_id');
  355. $node_id = (int)getProp($data, 'node_id');
  356. $this->checkCanvas($canvas_id);
  357. $node = $this->getNode($canvas_id, $node_id);
  358. $now = date('Y-m-d H:i:s');
  359. DB::beginTransaction();
  360. try {
  361. DB::table('mp_canvas_nodes')->where('id', $node_id)->update(['is_deleted' => 1, 'updated_at' => $now]);
  362. DB::table('mp_canvas_node_rels')
  363. ->where('canvas_id', $canvas_id)
  364. ->where(function ($query) use ($node_id) {
  365. $query->where('node_id_a', $node_id)
  366. ->orWhere('node_id_b', $node_id);
  367. })
  368. ->delete();
  369. if ($node->node_type === 'segment') {
  370. DB::table('mp_canvas_segments')
  371. ->where('canvas_id', $canvas_id)
  372. ->where('node_id', $node_id)
  373. ->update(['is_deleted' => 1, 'updated_at' => $now]);
  374. }
  375. DB::commit();
  376. } catch (\Throwable $e) {
  377. DB::rollBack();
  378. Utils::throwError('20003:删除节点失败');
  379. }
  380. return ['success' => 1];
  381. }
  382. /**
  383. * 画布关系图(供前端渲染)
  384. *
  385. * 不传 node_id 时返回画布全部节点与关联;传 node_id 时返回以该节点为中心、
  386. * 深度为 depth 的局部子图(按广度优先收集邻居;rels 只包含 BFS 展开过程中触及的边,
  387. * 即 depth=1 时仅返回中心节点的直接关联),适合大画布局部加载。
  388. * 局部子图模式下,每个节点/每条关联附带 level 字段(节点为距中心的跳数,
  389. * 关联为 BFS 展开时首次被触及的轮次),另附 levels 摘要便于前端逐层加载与动画。
  390. *
  391. * @param array $data canvas_id-画布ID node_id-中心节点ID(可选) depth-子图深度(可选,默认1)
  392. * @return array
  393. */
  394. public function graph($data)
  395. {
  396. $canvas_id = (int)getProp($data, 'canvas_id');
  397. $node_id = (int)getProp($data, 'node_id', 0);
  398. $depth = min(5, max(1, (int)getProp($data, 'depth', 1)));
  399. $this->checkCanvas($canvas_id);
  400. if ($node_id) {
  401. return $this->getSubGraph($canvas_id, $node_id, $depth);
  402. }
  403. return $this->getGraph($canvas_id);
  404. }
  405. /* ------------------------------------------------------------------ */
  406. /* 私有方法 */
  407. /* ------------------------------------------------------------------ */
  408. /**
  409. * 校验画布归属并返回画布
  410. *
  411. * @param int $canvas_id
  412. * @return object
  413. */
  414. private function checkCanvas($canvas_id)
  415. {
  416. if (!$canvas_id) {
  417. Utils::throwError('20003:请提供画布ID');
  418. }
  419. $canvas = DB::table('mp_canvases')
  420. ->where('id', $canvas_id)
  421. ->where('uid', Site::getUid())
  422. ->where('cpid', Site::getCpid())
  423. ->where('is_deleted', 0)
  424. ->first();
  425. if (!$canvas) {
  426. Utils::throwError('20003:画布不存在或无权访问');
  427. }
  428. return $canvas;
  429. }
  430. /**
  431. * 校验节点归属并返回节点
  432. *
  433. * @param int $canvas_id
  434. * @param int $node_id
  435. * @return object
  436. */
  437. private function getNode($canvas_id, $node_id)
  438. {
  439. if (!$node_id) {
  440. Utils::throwError('20003:请提供节点ID');
  441. }
  442. $node = DB::table('mp_canvas_nodes')
  443. ->where('id', $node_id)
  444. ->where('canvas_id', $canvas_id)
  445. ->where('is_deleted', 0)
  446. ->first();
  447. if (!$node) {
  448. Utils::throwError('20003:节点不存在');
  449. }
  450. return $node;
  451. }
  452. /**
  453. * 同步分镜数据表(创建或更新)
  454. *
  455. * @param int $canvas_id
  456. * @param int $node_id
  457. * @param array $data
  458. * @param string $now
  459. * @return void
  460. */
  461. private function syncSegment($canvas_id, $node_id, $data, $now)
  462. {
  463. $seg = getProp($data, 'segment');
  464. $seg = is_array($seg) ? $seg : [];
  465. $seg_fields = [
  466. 'segment_number' => (int)getProp($seg, 'segment_number', 1),
  467. 'name' => (string)getProp($seg, 'name', getProp($data, 'name', '')),
  468. 'text_prompt' => (string)getProp($seg, 'text_prompt', getProp($data, 'text_prompt', '')),
  469. 'pic_prompt' => (string)getProp($seg, 'pic_prompt', getProp($data, 'pic_prompt', '')),
  470. 'image_url' => (string)getProp($seg, 'image_url', getProp($data, 'image_url', '')),
  471. 'video_url' => (string)getProp($seg, 'video_url', getProp($data, 'video_url', '')),
  472. 'audio_url' => (string)getProp($seg, 'audio_url', getProp($data, 'audio_url', '')),
  473. 'duration' => $this->nullableInt(getProp($seg, 'duration', getProp($data, 'duration', null))),
  474. 'status' => (string)getProp($seg, 'status', 'draft'),
  475. 'ext' => $this->jsonEncode(getProp($seg, 'ext', null))
  476. ];
  477. $exists = DB::table('mp_canvas_segments')
  478. ->where('canvas_id', $canvas_id)
  479. ->where('node_id', $node_id)
  480. ->where('is_deleted', 0)
  481. ->first();
  482. if ($exists) {
  483. $seg_fields['updated_at'] = $now;
  484. DB::table('mp_canvas_segments')->where('id', $exists->id)->update($seg_fields);
  485. } else {
  486. $seg_fields['canvas_id'] = $canvas_id;
  487. $seg_fields['node_id'] = $node_id;
  488. $seg_fields['uid'] = Site::getUid();
  489. $seg_fields['cpid'] = Site::getCpid();
  490. $seg_fields['is_deleted'] = 0;
  491. $seg_fields['created_at'] = $now;
  492. $seg_fields['updated_at'] = $now;
  493. DB::table('mp_canvas_segments')->insert($seg_fields);
  494. }
  495. }
  496. /**
  497. * 获取以指定节点为中心的局部子图(广度优先,最多 depth 层)
  498. *
  499. * 节点集合为距中心不超过 depth 跳的所有节点;
  500. * 关系集合为 BFS 展开过程中触及的边(至少一端距中心不超过 depth-1 跳),
  501. * 因此 depth=1 时只返回中心节点的直接关联,不会返回"邻居之间的边"。
  502. * 返回的节点带 level(距中心跳数,中心为 0),关联带 level(首次被触及的轮次),
  503. * 并提供 levels 摘要 [{level, node_ids, rel_ids}] 供前端逐层渲染。
  504. *
  505. * @param int $canvas_id
  506. * @param int $node_id
  507. * @param int $depth
  508. * @return array
  509. */
  510. private function getSubGraph($canvas_id, $node_id, $depth)
  511. {
  512. $this->getNode($canvas_id, $node_id);
  513. // 广度优先收集节点(节点只入队一次,避免环导致死循环)
  514. $collectedMap = [$node_id => 0];
  515. $collected = [$node_id];
  516. $frontier = [$node_id];
  517. $edgeMap = [];
  518. for ($i = 0; $i < $depth; $i++) {
  519. $level = $i + 1;
  520. $rels = DB::table('mp_canvas_node_rels')
  521. ->where('canvas_id', $canvas_id)
  522. ->where(function ($query) use ($frontier) {
  523. $query->whereIn('node_id_a', $frontier)
  524. ->orWhereIn('node_id_b', $frontier);
  525. })
  526. ->get();
  527. $next = [];
  528. foreach ($rels as $rel) {
  529. $a = (int)$rel->node_id_a;
  530. $b = (int)$rel->node_id_b;
  531. // 只记录 BFS 展开过程中触及的边(去重),不返回"邻居之间的边"
  532. if (!isset($edgeMap[$a . '_' . $b])) {
  533. $edgeMap[$a . '_' . $b] = [
  534. 'id' => (int)$rel->id,
  535. 'node_id_a' => $a,
  536. 'node_id_b' => $b,
  537. 'level' => $level
  538. ];
  539. }
  540. foreach ([$a, $b] as $nid) {
  541. if (!isset($collectedMap[$nid])) {
  542. $collectedMap[$nid] = $level;
  543. $collected[] = $nid;
  544. $next[] = $nid;
  545. }
  546. }
  547. }
  548. if (!$next) {
  549. break;
  550. }
  551. $frontier = $next;
  552. }
  553. $nodes = DB::table('mp_canvas_nodes')
  554. ->where('canvas_id', $canvas_id)
  555. ->whereIn('id', $collected)
  556. ->where('is_deleted', 0)
  557. ->orderBy('z_index')
  558. ->orderBy('id')
  559. ->get()
  560. ->map(function ($node) use ($canvas_id, $collectedMap) {
  561. $node = $this->formatNode($node, $canvas_id);
  562. $node['level'] = $collectedMap[$node['id']];
  563. return $node;
  564. })
  565. ->toArray();
  566. $rels = array_values($edgeMap);
  567. usort($rels, function ($x, $y) {
  568. return $x['id'] - $y['id'];
  569. });
  570. // 层级摘要:每层包含的节点ID与关联ID
  571. $levelNodes = [];
  572. $levelRels = [];
  573. foreach ($collected as $nid) {
  574. $levelNodes[$collectedMap[$nid]][] = $nid;
  575. }
  576. foreach ($rels as $rel) {
  577. $levelRels[$rel['level']][] = $rel['id'];
  578. }
  579. $levels = [];
  580. for ($lv = 0; $lv <= $depth; $lv++) {
  581. if (isset($levelNodes[$lv]) || isset($levelRels[$lv])) {
  582. $levels[] = [
  583. 'level' => $lv,
  584. 'node_ids' => isset($levelNodes[$lv]) ? $levelNodes[$lv] : [],
  585. 'rel_ids' => isset($levelRels[$lv]) ? $levelRels[$lv] : []
  586. ];
  587. }
  588. }
  589. return [
  590. 'center_node_id' => $node_id,
  591. 'depth' => $depth,
  592. 'levels' => $levels,
  593. 'nodes' => $nodes,
  594. 'rels' => $rels
  595. ];
  596. }
  597. /**
  598. * 获取画布全量节点与关联
  599. *
  600. * @param int $canvas_id
  601. * @return array
  602. */
  603. private function getGraph($canvas_id)
  604. {
  605. $nodes = DB::table('mp_canvas_nodes')
  606. ->where('canvas_id', $canvas_id)
  607. ->where('is_deleted', 0)
  608. ->orderBy('z_index')
  609. ->orderBy('id')
  610. ->get()
  611. ->map(function ($node) use ($canvas_id) {
  612. return $this->formatNode($node, $canvas_id);
  613. })
  614. ->toArray();
  615. $rels = DB::table('mp_canvas_node_rels')
  616. ->where('canvas_id', $canvas_id)
  617. ->orderBy('id')
  618. ->get()
  619. ->map(function ($rel) {
  620. return [
  621. 'id' => (int)$rel->id,
  622. 'node_id_a' => (int)$rel->node_id_a,
  623. 'node_id_b' => (int)$rel->node_id_b
  624. ];
  625. })
  626. ->toArray();
  627. return ['nodes' => $nodes, 'rels' => $rels];
  628. }
  629. /**
  630. * 格式化节点输出(分镜节点合并分镜数据表字段)
  631. *
  632. * @param object $node
  633. * @param int $canvas_id
  634. * @return array
  635. */
  636. private function formatNode($node, $canvas_id)
  637. {
  638. $node = (array)$node;
  639. $node['ext'] = $node['ext'] ? json_decode($node['ext'], true) : null;
  640. if ($node['node_type'] === 'segment') {
  641. $segment = DB::table('mp_canvas_segments')
  642. ->where('canvas_id', $canvas_id)
  643. ->where('node_id', $node['id'])
  644. ->where('is_deleted', 0)
  645. ->first();
  646. if ($segment) {
  647. $segment = (array)$segment;
  648. // 分镜数据表为准,覆盖节点级同名关键字段
  649. foreach (['name', 'text_prompt', 'pic_prompt', 'image_url', 'video_url', 'audio_url', 'duration', 'ext'] as $field) {
  650. $node[$field] = $segment[$field];
  651. }
  652. $node['segment_number'] = $segment['segment_number'];
  653. $node['status'] = $segment['status'];
  654. } else {
  655. $node['segment_number'] = null;
  656. $node['status'] = null;
  657. }
  658. }
  659. $node['id'] = (int)$node['id'];
  660. $node['canvas_id'] = (int)$node['canvas_id'];
  661. $node['pos_x'] = (float)$node['pos_x'];
  662. $node['pos_y'] = (float)$node['pos_y'];
  663. $node['z_index'] = (int)$node['z_index'];
  664. $node['duration'] = $node['duration'] !== null ? (int)$node['duration'] : null;
  665. $node['source_product_id'] = $node['source_product_id'] !== null ? (int)$node['source_product_id'] : null;
  666. $node['source_anime_id'] = $node['source_anime_id'] !== null ? (int)$node['source_anime_id'] : null;
  667. $node['source_episode_id'] = $node['source_episode_id'] !== null ? (int)$node['source_episode_id'] : null;
  668. return $node;
  669. }
  670. /**
  671. * 获取节点所有关联节点ID
  672. *
  673. * @param int $canvas_id
  674. * @param int $node_id
  675. * @return array
  676. */
  677. private function getNodeRelIds($canvas_id, $node_id)
  678. {
  679. $rels = DB::table('mp_canvas_node_rels')
  680. ->where('canvas_id', $canvas_id)
  681. ->where(function ($query) use ($node_id) {
  682. $query->where('node_id_a', $node_id)
  683. ->orWhere('node_id_b', $node_id);
  684. })
  685. ->get();
  686. $ids = [];
  687. foreach ($rels as $rel) {
  688. $ids[] = (int)($rel->node_id_a == $node_id ? $rel->node_id_b : $rel->node_id_a);
  689. }
  690. return $ids;
  691. }
  692. /**
  693. * 获取节点的关联节点完整信息
  694. *
  695. * @param int $canvas_id
  696. * @param int $node_id
  697. * @return array
  698. */
  699. private function getRelatedNodes($canvas_id, $node_id)
  700. {
  701. $ids = $this->getNodeRelIds($canvas_id, $node_id);
  702. if (!$ids) {
  703. return [];
  704. }
  705. return DB::table('mp_canvas_nodes')
  706. ->where('canvas_id', $canvas_id)
  707. ->whereIn('id', $ids)
  708. ->where('is_deleted', 0)
  709. ->orderBy('id')
  710. ->get()
  711. ->map(function ($node) use ($canvas_id) {
  712. return $this->formatNode($node, $canvas_id);
  713. })
  714. ->toArray();
  715. }
  716. /**
  717. * 可空整型
  718. *
  719. * @param mixed $value
  720. * @return int|null
  721. */
  722. private function nullableInt($value)
  723. {
  724. return ($value === null || $value === '') ? null : (int)$value;
  725. }
  726. /**
  727. * 可空浮点
  728. *
  729. * @param mixed $value
  730. * @return float|null
  731. */
  732. private function nullableFloat($value)
  733. {
  734. return ($value === null || $value === '') ? null : (float)$value;
  735. }
  736. /**
  737. * JSON 编码(已是 JSON 字符串则原样保留)
  738. *
  739. * @param mixed $value
  740. * @return string|null
  741. */
  742. private function jsonEncode($value)
  743. {
  744. if ($value === null || $value === '') {
  745. return null;
  746. }
  747. if (is_string($value)) {
  748. return json_decode($value, true) !== null ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
  749. }
  750. return json_encode($value, JSON_UNESCAPED_UNICODE);
  751. }
  752. }