|
|
@@ -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
|