|
|
@@ -0,0 +1,836 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Services\Canvas;
|
|
|
+
|
|
|
+use App\Facade\Site;
|
|
|
+use App\Libs\Utils;
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 画布模式服务
|
|
|
+ *
|
|
|
+ * 核心模型:
|
|
|
+ * 画布(mp_canvases) -> 节点(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'];
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 画布列表(分页)
|
|
|
+ *
|
|
|
+ * @param array $data keyword-关键字搜索 page-页码 page_size-每页数量
|
|
|
+ * @return array
|
|
|
+ */
|
|
|
+ public function canvasList($data)
|
|
|
+ {
|
|
|
+ $uid = Site::getUid();
|
|
|
+ $cpid = Site::getCpid();
|
|
|
+ $keyword = trim((string)getProp($data, 'name', ''));
|
|
|
+ $canvas_id = getProp($data, 'canvas_id');
|
|
|
+ $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('id as canvas_id', 'name', 'description', 'cover_url', 'created_at',
|
|
|
+ DB::raw('(select count(id) from mp_canvas_nodes where canvas_id = mp_canvases.id and is_deleted = 0) as node_count'),
|
|
|
+ DB::raw('(select count(id) from mp_canvas_segments where canvas_id = mp_canvases.id and is_deleted = 0) as segment_count'));
|
|
|
+
|
|
|
+ if ($canvas_id) {
|
|
|
+ $query->where('id', $canvas_id);
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ if ($keyword !== '') {
|
|
|
+ $query->where('name', 'like', "%{$keyword}%");
|
|
|
+ }
|
|
|
+
|
|
|
+ $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');
|
|
|
+ return $item;
|
|
|
+ })->toArray();
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'meta' => getMeta($list),
|
|
|
+ 'list' => $items
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建画布
|
|
|
+ *
|
|
|
+ * @param array $data name-画布名称 description-描述 cover_url-封面
|
|
|
+ * @return array
|
|
|
+ */
|
|
|
+ public function createCanvas($data)
|
|
|
+ {
|
|
|
+ $name = trim((string)getProp($data, 'name', ''));
|
|
|
+ if ($name === '') {
|
|
|
+ Utils::throwError('20003:请提供画布名称');
|
|
|
+ }
|
|
|
+
|
|
|
+ $uid = Site::getUid();
|
|
|
+ $cpid = Site::getCpid();
|
|
|
+ $now = date('Y-m-d H:i:s');
|
|
|
+
|
|
|
+ $canvas_id = DB::table('mp_canvases')->insertGetId([
|
|
|
+ 'uid' => $uid,
|
|
|
+ 'cpid' => $cpid,
|
|
|
+ 'name' => $name,
|
|
|
+ 'description' => (string)getProp($data, 'description', ''),
|
|
|
+ 'cover_url' => (string)getProp($data, 'cover_url', ''),
|
|
|
+ 'is_deleted' => 0,
|
|
|
+ 'created_at' => $now,
|
|
|
+ 'updated_at' => $now
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (!$canvas_id) {
|
|
|
+ Utils::throwError('20003:创建画布失败');
|
|
|
+ }
|
|
|
+
|
|
|
+ return ['canvas_id' => $canvas_id];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 编辑画布(只更新传入的字段)
|
|
|
+ *
|
|
|
+ * @param array $data canvas_id-画布ID 其余可选字段
|
|
|
+ * @return array
|
|
|
+ */
|
|
|
+ public function editCanvas($data)
|
|
|
+ {
|
|
|
+ $canvas_id = (int)getProp($data, 'canvas_id');
|
|
|
+ $this->checkCanvas($canvas_id);
|
|
|
+
|
|
|
+ $update = ['updated_at' => date('Y-m-d H:i:s')];
|
|
|
+
|
|
|
+ $editable = ['name', 'description', 'cover_url'];
|
|
|
+ foreach ($editable as $field) {
|
|
|
+ if (array_key_exists($field, $data)) {
|
|
|
+ $update[$field] = (string)$data[$field];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ DB::table('mp_canvases')->where('id', $canvas_id)->update($update);
|
|
|
+
|
|
|
+ return ['success' => 1];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 删除画布(软删除画布/节点/分镜数据,物理删除关联)
|
|
|
+ *
|
|
|
+ * @param array $data canvas_id-画布ID
|
|
|
+ * @return array
|
|
|
+ */
|
|
|
+ public function delCanvas($data)
|
|
|
+ {
|
|
|
+ $canvas_id = (int)getProp($data, 'canvas_id');
|
|
|
+ $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
|
|
|
+ * @return array
|
|
|
+ */
|
|
|
+ public function canvasDetail($data)
|
|
|
+ {
|
|
|
+ $canvas_id = (int)getProp($data, 'canvas_id');
|
|
|
+ $canvas = $this->checkCanvas($canvas_id);
|
|
|
+ $canvas = (array)$canvas;
|
|
|
+ $canvas['created_at'] = transDate($canvas['created_at'], 'Y-m-d H:i:s');
|
|
|
+ $canvas['updated_at'] = transDate($canvas['updated_at'], 'Y-m-d H:i:s');
|
|
|
+
|
|
|
+ return array_merge($canvas, $this->getGraph($canvas_id));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 保存节点(创建/更新)
|
|
|
+ *
|
|
|
+ * 通用字段直接写 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 int $canvas_id
|
|
|
+ * @return object
|
|
|
+ */
|
|
|
+ private function checkCanvas($canvas_id)
|
|
|
+ {
|
|
|
+ if (!$canvas_id) {
|
|
|
+ Utils::throwError('20003:请提供画布ID');
|
|
|
+ }
|
|
|
+ $canvas = DB::table('mp_canvases')
|
|
|
+ ->where('id', $canvas_id)
|
|
|
+ ->where('uid', Site::getUid())
|
|
|
+ ->where('cpid', Site::getCpid())
|
|
|
+ ->where('is_deleted', 0)
|
|
|
+ ->first();
|
|
|
+ if (!$canvas) {
|
|
|
+ Utils::throwError('20003:画布不存在或无权访问');
|
|
|
+ }
|
|
|
+ return $canvas;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 校验节点归属并返回节点
|
|
|
+ *
|
|
|
+ * @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);
|
|
|
+ }
|
|
|
+}
|