lh vor 1 Monat
Ursprung
Commit
1e65d745d3
2 geänderte Dateien mit 155 neuen und 300 gelöschten Zeilen
  1. 1 13
      app/Http/Controllers/Canvas/CanvasController.php
  2. 154 287
      app/Services/Canvas/CanvasService.php

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

@@ -40,23 +40,11 @@ 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);
     }
 

+ 154 - 287
app/Services/Canvas/CanvasService.php

@@ -29,9 +29,6 @@ class CanvasService
     /** 关联来源类型白名单 */
     const SOURCE_TYPES = ['none', 'product', 'episode'];
 
-    /** 当前支持的画布文档 schema 版本 */
-    const SUPPORTED_SCHEMA_VERSION = 1;
-
     /** 画布文档大小与图规模限制 */
     const MAX_DOCUMENT_BYTES = 2097152; // 2MB
     const MAX_NODES         = 2000;
@@ -61,8 +58,7 @@ class CanvasService
             ->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', 'schema_version', 'revision',
-                'created_at', 'updated_at');
+                'anime_id', 'episode_id', 'episode_number', 'created_at', 'updated_at');
 
         if ($anime_id) {
             $query->where('anime_id', $anime_id);
@@ -96,9 +92,11 @@ class CanvasService
     }
 
     /**
-     * 创建画布(完整文档模式,兼容旧的 name-only 创建)
+     * 创建画布
+     *
+     * 必填:name、document;canvas_id 可选(缺省自动生成);可选:anime_id、episode_id、episode_number
      *
-     * @param array $data 完整画布文档或旧版 name/description/cover_url
+     * @param array $data
      * @return array
      */
     public function createCanvas($data)
@@ -107,54 +105,39 @@ class CanvasService
         $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);
+        $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-only 创建,保留兼容
-        $name = trim((string)getProp($data, 'name', ''));
+        $name = trim((string)$this->docProp($data, 'name', ''));
         if ($name === '') {
             Utils::throwError('20003:请提供画布名称');
         }
-        $canvas_uuid = $this->generateCanvasId();
+
+        $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)getProp($data, 'description', ''),
-            'cover_url'      => (string)getProp($data, 'cover_url', ''),
-            'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
-            'revision'       => 1,
-            'document'       => null,
+            '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
@@ -163,13 +146,15 @@ class CanvasService
             Utils::throwError('20003:创建画布失败');
         }
 
-        return $this->canvasDetail(['canvas_id' => $canvas_uuid]);
+        return $this->documentResult($canvas_uuid, $name, $document);
     }
 
     /**
-     * 保存画布完整文档;revision 不匹配时返回冲突标记
+     * 保存画布完整文档
      *
-     * @param array $data 完整画布文档
+     * 必填:canvas_id、name、document;可选:anime_id、episode_id、episode_number(不传保留原值)
+     *
+     * @param array $data
      * @return array
      */
     public function editCanvas($data)
@@ -180,32 +165,33 @@ class CanvasService
         }
 
         $canvas = $this->checkCanvas($canvas_id);
-        $doc    = $this->validateDocument($data, false, $canvas);
 
-        if ((int)$doc['revision'] !== (int)$canvas->revision) {
-            return [
-                'revision_conflict' => true,
-                'canvas_id'         => $this->canvasKey($canvas),
-                'revision'          => (int)$canvas->revision
-            ];
+        $name = trim((string)$this->docProp($data, 'name', ''));
+        if ($name === '') {
+            Utils::throwError('20003:请提供画布名称');
         }
 
-        $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')
-        ]);
+        $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;
+            }
+        }
 
-        return $this->canvasDetail(['canvas_id' => $doc['canvas_id']]);
+        DB::table('mp_canvases')->where('id', $canvas->id)->update($update);
+
+        return $this->documentResult($this->canvasKey($canvas), $name, $document);
     }
 
     /**
@@ -245,7 +231,14 @@ class CanvasService
     {
         $canvas_id = (string)getProp($data, 'canvas_id', '');
         $canvas    = $this->checkCanvas($canvas_id);
-        return $this->buildDocument($canvas);
+
+        $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));
     }
 
     /**
@@ -582,270 +575,146 @@ class CanvasService
     }
 
     /**
-     * 判断请求是否为完整文档创建
+     * 提取前端传入的 document(必填字段)
      *
-     * @param mixed $data
-     * @return bool
+     * @param array $data
+     * @return mixed
      */
-    private function isDocumentRequest($data)
+    private function extractDocument($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)
-        );
+        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 array       $data
-     * @param bool        $isCreate
-     * @param object|null $canvas
-     * @return array
+     * @param mixed $document
+     * @return string|null
      */
-    private function validateDocument($data, $isCreate, $canvas = null)
+    private function documentForStorage($document)
     {
-        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');
+        if ($document === null || $document === '') {
+            return null;
         }
-
-        $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与画布不一致');
+        if (is_array($document) || is_object($document)) {
+            $json = json_encode($document, JSON_UNESCAPED_UNICODE);
+            if ($json === false) {
+                Utils::throwError('20003:document格式不正确');
             }
-            $canvas_id = $key;
-        }
-
-        $revision = $isCreate ? 1 : (int)getProp($data, 'revision', -1);
-        if (!$isCreate && $revision < 0) {
-            Utils::throwError('20003:缺少revision');
+            return $json;
         }
-
-        $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格式不正确');
+        if (is_string($document)) {
+            return $document;
         }
-        $scope = $this->checkScope($scope);
+        Utils::throwError('20003:document格式不正确');
+    }
 
-        $graph = getProp($data, 'graph', []);
-        if (!is_array($graph)) {
-            Utils::throwError('20003:graph格式不正确');
+    /**
+     * 存储的 document 返回给前端:JSON 字符串解码为对象;非 JSON 字符串原样返回
+     *
+     * @param mixed $document
+     * @return mixed
+     */
+    private function documentForResponse($document)
+    {
+        if ($document === null || $document === '') {
+            return null;
         }
-        $graph = $this->normalizeGraph($graph);
-
-        $preferences = getProp($data, 'preferences', []);
-        if (!is_array($preferences)) {
-            $preferences = [];
+        if (is_array($document) || is_object($document)) {
+            return $document;
         }
-
-        $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:画布文档大小超出限制');
+        $raw = (string)$document;
+        $doc = json_decode($raw, true);
+        if ($doc === null && trim($raw) !== 'null') {
+            return $raw;
         }
-        $this->checkTextAndUrlLimits($doc);
-
         return $doc;
     }
 
     /**
-     * 校验 scope 归属:动漫属于当前用户,剧集属于该动漫
+     * 组装画布文档相关返回结构:document 原样返回,canvas_id 由服务端维护
      *
-     * @param array $scope
+     * @param string      $canvas_id
+     * @param mixed       $document
      * @return array
      */
-    private function checkScope($scope)
+    private function documentResult($canvas_id, $name, $document)
     {
-        $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
+            'canvas_id' => (string)$canvas_id,
+            'name'      => (string)$name,
+            'document'  => $this->documentForResponse($document)
         ];
     }
 
     /**
-     * 图结构最小校验:节点/边数量、ID 唯一、边端点存在
+     * 校验 document 存储大小(仅整体大小限制,不解析内部结构)
      *
-     * @param array $graph
-     * @return array
+     * @param string|null $document
+     * @return void
      */
-    private function normalizeGraph($graph)
+    private function checkDocumentSize($document)
     {
-        $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;
-            }
+        if ($document !== null && strlen($document) > self::MAX_DOCUMENT_BYTES) {
+            Utils::throwError('20003:画布文档大小超出限制');
         }
-
-        return [
-            'viewport' => $viewport,
-            'nodes'    => $nodes,
-            'edges'    => $edges
-        ];
     }
 
     /**
-     * 递归限制文档内文本与 URL 长度
+     * 读取请求中 document 相关字段(兼容 document 放在顶层或 document 字段内)
      *
-     * @param mixed $value
-     * @return void
+     * @param array  $data
+     * @param string $key
+     * @param mixed  $default
+     * @return mixed
      */
-    private function checkTextAndUrlLimits($value)
+    private function docProp($data, $key, $default = '')
     {
-        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 (array_key_exists($key, $data)) {
+            return $data[$key];
         }
-        if (!is_array($value)) {
-            return;
-        }
-        foreach ($value as $item) {
-            $this->checkTextAndUrlLimits($item);
+        $doc = getProp($data, 'document', null);
+        if (is_array($doc) && array_key_exists($key, $doc)) {
+            return $doc[$key];
         }
+        return $default;
     }
 
     /**
-     * 组装完整文档;旧画布无 document 时从节点/关联表构建
+     * 读取画布元数据列(anime_id/episode_id/episode_number),
+     * 兼容顶层字段、document 顶层字段、document.scope 三种位置;不影响 document 原样保存
      *
-     * @param object $canvas
-     * @return array
+     * @param array  $data
+     * @param string $key
+     * @param mixed  $default
+     * @return int|null
      */
-    private function buildDocument($canvas)
+    private function scopeProp($data, $key, $default = 0)
     {
-        $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;
+        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];
             }
         }
-        return $this->buildLegacyDocument($canvas);
+        $scope = getProp($data, 'scope', null);
+        if (is_array($scope) && array_key_exists($key, $scope)) {
+            return (int)$scope[$key];
+        }
+        return $default;
     }
 
     /**
@@ -892,9 +761,7 @@ class CanvasService
         }
 
         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'          => [