瀏覽代碼

优化画布管理的五个接口,适配前端格式

lh 1 月之前
父節點
當前提交
7fee3ffe45
共有 2 個文件被更改,包括 503 次插入70 次删除
  1. 13 1
      app/Http/Controllers/Canvas/CanvasController.php
  2. 490 69
      app/Services/Canvas/CanvasService.php

+ 13 - 1
app/Http/Controllers/Canvas/CanvasController.php

@@ -40,11 +40,23 @@ class CanvasController extends BaseController
         return $this->success($result);
     }
 
-    // 编辑画布
+    // 保存画布完整文档;revision 冲突返回 409 和服务端当前 revision
     public function editCanvas(Request $request)
     {
         $data   = $request->all();
         $result = $this->CanvasService->editCanvas($data);
+
+        if (isset($result['revision_conflict']) && $result['revision_conflict']) {
+            return response()->json([
+                'msg'  => '画布已被其他会话修改,请拉取最新版本',
+                'code' => 409,
+                'data' => [
+                    'canvas_id' => $result['canvas_id'],
+                    'revision'  => $result['revision']
+                ]
+            ], 409)->setEncodingOptions(JSON_UNESCAPED_UNICODE);
+        }
+
         return $this->success($result);
     }
 

+ 490 - 69
app/Services/Canvas/CanvasService.php

@@ -29,44 +29,59 @@ class CanvasService
     /** 关联来源类型白名单 */
     const SOURCE_TYPES = ['none', 'product', 'episode'];
 
+    /** 当前支持的画布文档 schema 版本 */
+    const SUPPORTED_SCHEMA_VERSION = 1;
+
+    /** 画布文档大小与图规模限制 */
+    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 keyword-关键字搜索 page-页码 page_size-每页数量
+     * @param array $data anime_id-动漫ID(可选) episode_id-剧集ID(可选) 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)));
+        $uid        = Site::getUid();
+        $cpid       = Site::getCpid();
+        $anime_id   = (int)getProp($data, 'anime_id', 0);
+        $episode_id = (int)getProp($data, 'episode_id', 0);
+        $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'));
+            ->select('canvas_uuid as canvas_id', 'name as title', 'description', 'cover_url',
+                'anime_id', 'episode_id', 'episode_number', 'schema_version', 'revision',
+                'created_at', 'updated_at');
 
-        if ($canvas_id) {
-            $query->where('id', $canvas_id);
+        if ($anime_id) {
+            $query->where('anime_id', $anime_id);
         }
-
-
-        if ($keyword !== '') {
-            $query->where('name', 'like', "%{$keyword}%");
+        if ($episode_id) {
+            $query->where('episode_id', $episode_id);
         }
 
         $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['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();
 
@@ -77,63 +92,116 @@ class CanvasService
     }
 
     /**
-     * 创建画布
+     * 创建画布(完整文档模式,兼容旧的 name-only 创建)
      *
-     * @param array $data name-画布名称 description-描述 cover_url-封面
+     * @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');
 
+        if ($this->isDocumentRequest($data)) {
+            $doc         = $this->validateDocument($data, true);
+            $canvas_uuid = $doc['canvas_id'];
+            if (DB::table('mp_canvases')->where('canvas_uuid', $canvas_uuid)->exists()) {
+                Utils::throwError('20003:画布ID已存在');
+            }
+
+            $row = [
+                'uid'            => $uid,
+                'cpid'           => $cpid,
+                'canvas_uuid'    => $canvas_uuid,
+                'name'           => $doc['title'],
+                'description'    => (string)getProp($data, 'description', ''),
+                'cover_url'      => (string)getProp($data, 'cover_url', ''),
+                'anime_id'       => $doc['scope']['anime_id'],
+                'episode_id'     => $doc['scope']['episode_id'],
+                'episode_number' => $doc['scope']['episode_number'],
+                'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
+                'revision'       => 1,
+                'document'       => json_encode($doc, JSON_UNESCAPED_UNICODE),
+                'is_deleted'     => 0,
+                'created_at'     => $now,
+                'updated_at'     => $now
+            ];
+            $canvas_id = DB::table('mp_canvases')->insertGetId($row);
+            if (!$canvas_id) {
+                Utils::throwError('20003:创建画布失败');
+            }
+            $row['id'] = $canvas_id;
+            return $this->buildDocument((object)$row);
+        }
+
+        // 旧版 name-only 创建,保留兼容
+        $name = trim((string)getProp($data, 'name', ''));
+        if ($name === '') {
+            Utils::throwError('20003:请提供画布名称');
+        }
+        $canvas_uuid = $this->generateCanvasId();
         $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
+            'uid'            => $uid,
+            'cpid'           => $cpid,
+            'canvas_uuid'    => $canvas_uuid,
+            'name'           => $name,
+            'description'    => (string)getProp($data, 'description', ''),
+            'cover_url'      => (string)getProp($data, 'cover_url', ''),
+            'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
+            'revision'       => 1,
+            'document'       => null,
+            'is_deleted'     => 0,
+            'created_at'     => $now,
+            'updated_at'     => $now
         ]);
-
         if (!$canvas_id) {
             Utils::throwError('20003:创建画布失败');
         }
 
-        return ['canvas_id' => $canvas_id];
+        return $this->canvasDetail(['canvas_id' => $canvas_uuid]);
     }
 
     /**
-     * 编辑画布(只更新传入的字段)
+     * 保存画布完整文档;revision 不匹配时返回冲突标记
      *
-     * @param array $data canvas_id-画布ID 其余可选字段
+     * @param array $data 完整画布文档
      * @return array
      */
     public function editCanvas($data)
     {
-        $canvas_id = (int)getProp($data, 'canvas_id');
-        $this->checkCanvas($canvas_id);
+        $canvas_id = (string)getProp($data, 'canvas_id', '');
+        if ($canvas_id === '') {
+            Utils::throwError('20003:请提供画布ID');
+        }
 
-        $update = ['updated_at' => date('Y-m-d H:i:s')];
+        $canvas = $this->checkCanvas($canvas_id);
+        $doc    = $this->validateDocument($data, false, $canvas);
 
-        $editable = ['name', 'description', 'cover_url'];
-        foreach ($editable as $field) {
-            if (array_key_exists($field, $data)) {
-                $update[$field] = (string)$data[$field];
-            }
+        if ((int)$doc['revision'] !== (int)$canvas->revision) {
+            return [
+                'revision_conflict' => true,
+                'canvas_id'         => $this->canvasKey($canvas),
+                'revision'          => (int)$canvas->revision
+            ];
         }
 
-        DB::table('mp_canvases')->where('id', $canvas_id)->update($update);
+        $next_revision   = (int)$canvas->revision + 1;
+        $doc['revision'] = $next_revision;
+        $doc['canvas_id'] = $this->canvasKey($canvas);
+
+        DB::table('mp_canvases')->where('id', $canvas->id)->update([
+            'name'           => $doc['title'],
+            'anime_id'       => $doc['scope']['anime_id'],
+            'episode_id'     => $doc['scope']['episode_id'],
+            'episode_number' => $doc['scope']['episode_number'],
+            'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
+            'revision'       => $next_revision,
+            'document'       => json_encode($doc, JSON_UNESCAPED_UNICODE),
+            'updated_at'     => date('Y-m-d H:i:s')
+        ]);
 
-        return ['success' => 1];
+        return $this->canvasDetail(['canvas_id' => $doc['canvas_id']]);
     }
 
     /**
@@ -144,16 +212,16 @@ class CanvasService
      */
     public function delCanvas($data)
     {
-        $canvas_id = (int)getProp($data, 'canvas_id');
-        $this->checkCanvas($canvas_id);
-        $now = date('Y-m-d H:i:s');
+        $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::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();
@@ -164,20 +232,16 @@ class CanvasService
     }
 
     /**
-     * 画布详情(画布信息 + 全部节点 + 全部关联)
+     * 打开指定画布,返回完整文档
      *
-     * @param array $data canvas_id-画布ID
+     * @param array $data canvas_id-画布ID(字符串 canvas_uuid 或旧数字ID)
      * @return array
      */
     public function canvasDetail($data)
     {
-        $canvas_id = (int)getProp($data, 'canvas_id');
+        $canvas_id = (string)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));
+        return $this->buildDocument($canvas);
     }
 
     /**
@@ -464,20 +528,27 @@ class CanvasService
     /**
      * 校验画布归属并返回画布
      *
-     * @param int $canvas_id
+     * @param string|int $canvas_id
      * @return object
      */
     private function checkCanvas($canvas_id)
     {
-        if (!$canvas_id) {
+        if ($canvas_id === '' || $canvas_id === null) {
             Utils::throwError('20003:请提供画布ID');
         }
-        $canvas = DB::table('mp_canvases')
-            ->where('id', $canvas_id)
+
+        $query = DB::table('mp_canvases')
             ->where('uid', Site::getUid())
             ->where('cpid', Site::getCpid())
-            ->where('is_deleted', 0)
-            ->first();
+            ->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:画布不存在或无权访问');
         }
@@ -485,6 +556,356 @@ class CanvasService
     }
 
     /**
+     * 画布对外资源 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();
+    }
+
+    /**
+     * 判断请求是否为完整文档创建
+     *
+     * @param mixed $data
+     * @return bool
+     */
+    private function isDocumentRequest($data)
+    {
+        return is_array($data) && (
+            array_key_exists('graph', $data) ||
+            array_key_exists('schema_version', $data) ||
+            array_key_exists('title', $data) ||
+            array_key_exists('scope', $data) ||
+            array_key_exists('canvas_id', $data)
+        );
+    }
+
+    /**
+     * 校验并规范化完整画布文档
+     *
+     * @param array       $data
+     * @param bool        $isCreate
+     * @param object|null $canvas
+     * @return array
+     */
+    private function validateDocument($data, $isCreate, $canvas = null)
+    {
+        if (!is_array($data)) {
+            Utils::throwError('20003:请求体格式不正确');
+        }
+
+        $schema_version = (int)getProp($data, 'schema_version', self::SUPPORTED_SCHEMA_VERSION);
+        if ($schema_version !== self::SUPPORTED_SCHEMA_VERSION) {
+            Utils::throwError('20003:不支持的schema_version');
+        }
+
+        $canvas_id = (string)getProp($data, 'canvas_id', '');
+        if ($isCreate) {
+            if ($canvas_id === '') {
+                $canvas_id = $this->generateCanvasId();
+            }
+            if (mb_strlen($canvas_id) > 64) {
+                Utils::throwError('20003:canvas_id长度超出限制');
+            }
+        } else {
+            $key = $canvas ? $this->canvasKey($canvas) : '';
+            if ($canvas_id !== '' && $canvas_id !== $key && $canvas_id !== (string)getProp($canvas, 'id', 0)) {
+                Utils::throwError('20003:canvas_id与画布不一致');
+            }
+            $canvas_id = $key;
+        }
+
+        $revision = $isCreate ? 1 : (int)getProp($data, 'revision', -1);
+        if (!$isCreate && $revision < 0) {
+            Utils::throwError('20003:缺少revision');
+        }
+
+        $title = trim((string)getProp($data, 'title', getProp($data, 'name', '')));
+        if ($title === '') {
+            $title = '未命名画布';
+        }
+        if (mb_strlen($title) > self::MAX_TITLE_LENGTH) {
+            Utils::throwError('20003:画布标题长度超出限制');
+        }
+
+        $scope = getProp($data, 'scope', []);
+        if (!is_array($scope)) {
+            Utils::throwError('20003:scope格式不正确');
+        }
+        $scope = $this->checkScope($scope);
+
+        $graph = getProp($data, 'graph', []);
+        if (!is_array($graph)) {
+            Utils::throwError('20003:graph格式不正确');
+        }
+        $graph = $this->normalizeGraph($graph);
+
+        $preferences = getProp($data, 'preferences', []);
+        if (!is_array($preferences)) {
+            $preferences = [];
+        }
+
+        $doc = [
+            'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
+            'canvas_id'      => $canvas_id,
+            'revision'       => $revision,
+            'scope'          => $scope,
+            'title'          => $title,
+            'graph'          => $graph,
+            'preferences'    => $preferences
+        ];
+
+        if (strlen(json_encode($doc, JSON_UNESCAPED_UNICODE)) > self::MAX_DOCUMENT_BYTES) {
+            Utils::throwError('20003:画布文档大小超出限制');
+        }
+        $this->checkTextAndUrlLimits($doc);
+
+        return $doc;
+    }
+
+    /**
+     * 校验 scope 归属:动漫属于当前用户,剧集属于该动漫
+     *
+     * @param array $scope
+     * @return array
+     */
+    private function checkScope($scope)
+    {
+        $anime_id   = (int)getProp($scope, 'anime_id', 0);
+        $episode_id = (int)getProp($scope, 'episode_id', 0);
+        if (!$anime_id || !$episode_id) {
+            Utils::throwError('20003:scope.anime_id和scope.episode_id不能为空');
+        }
+
+        $anime = DB::table('mp_animes')
+            ->where('id', $anime_id)
+            ->where('user_id', Site::getUid())
+            ->where('is_deleted', 0)
+            ->first();
+        if (!$anime) {
+            Utils::throwError('20003:动漫不存在或无权访问');
+        }
+
+        $episode = DB::table('mp_anime_episodes')
+            ->where('id', $episode_id)
+            ->where('anime_id', $anime_id)
+            ->first();
+        if (!$episode) {
+            Utils::throwError('20003:剧集不存在或不属于该动漫');
+        }
+
+        return [
+            'anime_id'       => $anime_id,
+            'episode_id'     => $episode_id,
+            'episode_number' => (int)$episode->episode_number
+        ];
+    }
+
+    /**
+     * 图结构最小校验:节点/边数量、ID 唯一、边端点存在
+     *
+     * @param array $graph
+     * @return array
+     */
+    private function normalizeGraph($graph)
+    {
+        $viewport = getProp($graph, 'viewport', ['x' => 0, 'y' => 0, 'zoom' => 1]);
+        if (!is_array($viewport)) {
+            $viewport = ['x' => 0, 'y' => 0, 'zoom' => 1];
+        }
+        $nodes = getProp($graph, 'nodes', []);
+        $edges = getProp($graph, 'edges', []);
+        if (!is_array($nodes)) {
+            $nodes = [];
+        }
+        if (!is_array($edges)) {
+            $edges = [];
+        }
+
+        if (count($nodes) > self::MAX_NODES) {
+            Utils::throwError('20003:节点数超出限制');
+        }
+        if (count($edges) > self::MAX_EDGES) {
+            Utils::throwError('20003:边数超出限制');
+        }
+
+        $nodeIds = [];
+        foreach ($nodes as $node) {
+            if (!is_array($node)) {
+                Utils::throwError('20003:节点格式不正确');
+            }
+            $id = getProp($node, 'id', '');
+            if ($id === '' || $id === null || !is_scalar($id)) {
+                Utils::throwError('20003:节点ID不能为空');
+            }
+            $idKey = (string)$id;
+            if (isset($nodeIds[$idKey])) {
+                Utils::throwError('20003:节点ID重复:' . $idKey);
+            }
+            $nodeIds[$idKey] = true;
+        }
+
+        $edgeIds = [];
+        foreach ($edges as $edge) {
+            if (!is_array($edge)) {
+                Utils::throwError('20003:边格式不正确');
+            }
+            $source = (string)getProp($edge, 'source', '');
+            $target = (string)getProp($edge, 'target', '');
+            if (!isset($nodeIds[$source]) || !isset($nodeIds[$target])) {
+                Utils::throwError('20003:边端点不存在');
+            }
+
+            $edgeId = getProp($edge, 'id', '');
+            if ($edgeId !== '' && $edgeId !== null) {
+                if (!is_scalar($edgeId)) {
+                    Utils::throwError('20003:边ID格式不正确');
+                }
+                $edgeKey = (string)$edgeId;
+                if (isset($edgeIds[$edgeKey])) {
+                    Utils::throwError('20003:边ID重复:' . $edgeKey);
+                }
+                $edgeIds[$edgeKey] = true;
+            }
+        }
+
+        return [
+            'viewport' => $viewport,
+            'nodes'    => $nodes,
+            'edges'    => $edges
+        ];
+    }
+
+    /**
+     * 递归限制文档内文本与 URL 长度
+     *
+     * @param mixed $value
+     * @return void
+     */
+    private function checkTextAndUrlLimits($value)
+    {
+        if (is_string($value)) {
+            if (strlen($value) > self::MAX_TEXT_LENGTH) {
+                Utils::throwError('20003:文本内容长度超出限制');
+            }
+            if (preg_match('#^https?://#i', $value) && strlen($value) > self::MAX_URL_LENGTH) {
+                Utils::throwError('20003:URL长度超出限制');
+            }
+            return;
+        }
+        if (!is_array($value)) {
+            return;
+        }
+        foreach ($value as $item) {
+            $this->checkTextAndUrlLimits($item);
+        }
+    }
+
+    /**
+     * 组装完整文档;旧画布无 document 时从节点/关联表构建
+     *
+     * @param object $canvas
+     * @return array
+     */
+    private function buildDocument($canvas)
+    {
+        $raw = (string)getProp($canvas, 'document', '');
+        if ($raw !== '') {
+            $doc = json_decode($raw, true);
+            if (is_array($doc)) {
+                $doc['canvas_id']      = $this->canvasKey($canvas);
+                $doc['revision']       = (int)getProp($canvas, 'revision', 1);
+                $doc['schema_version'] = (int)getProp($canvas, 'schema_version', self::SUPPORTED_SCHEMA_VERSION);
+                $doc['scope']          = $this->scopeFromRow($canvas);
+                $doc['title']          = (string)getProp($canvas, 'name', getProp($doc, 'title', '未命名画布'));
+                if (!isset($doc['graph']) || !is_array($doc['graph'])) {
+                    $doc['graph'] = ['viewport' => ['x' => 0, 'y' => 0, 'zoom' => 1], 'nodes' => [], 'edges' => []];
+                }
+                if (!isset($doc['preferences']) || !is_array($doc['preferences'])) {
+                    $doc['preferences'] = ['minimap_visible' => true, 'snap_to_grid_enabled' => false];
+                }
+                return $doc;
+            }
+        }
+        return $this->buildLegacyDocument($canvas);
+    }
+
+    /**
+     * 从画布行组装 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 [
+            'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
+            'canvas_id'      => $this->canvasKey($canvas),
+            'revision'       => (int)getProp($canvas, 'revision', 1),
+            '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