fix(web): 本地优先保存与 design 页体验修复

- 保存先写本地缓存,登录后可选云端同步;退出登录不再因文件操作跳转登录

- 修复 Layui 遮罩残留、design2 保存后线条、toast 被 finally 提前关闭

- 保存过程恢复 loading spinner;成功提示 2.5 秒

- 云端 payload 瘦身与体积超限提示;后端 schema 迁移与 FileService 容错

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24kycj
2026-06-24 19:24:34 +08:00
parent 4d19d7e814
commit 5fb269cc0a
36 changed files with 894 additions and 358 deletions
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Core;
use PDOException;
use Throwable;
/**
* API 未捕获异常 → JSON 响应(避免空白 500)。
*/
final class ErrorHandler
{
public static function register(): void
{
set_exception_handler([self::class, 'handleException']);
}
public static function handleException(Throwable $e): void
{
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
if (!str_starts_with($path, '/api/')) {
http_response_code(500);
exit;
}
$debug = (bool)Config::get('app.debug', false);
$code = 'server_error';
$msg = '服务器错误,请稍后重试';
$status = 500;
if ($e instanceof PDOException) {
$msg = '数据库操作失败';
$sqlState = (string)$e->getCode();
$detail = $e->getMessage();
if (str_contains($detail, 'max_allowed_packet') || str_contains($detail, 'Packet too large')) {
$code = 'payload_too_large';
$msg = '文件过大,超出服务器数据库限制';
$status = 413;
} elseif (str_contains($detail, 'Unknown column') || str_contains($detail, "doesn't exist")) {
$msg = '数据库结构不完整,请导入 database/schema.sql 或执行 php scripts/migrate-schema.php';
} elseif ($sqlState === '22001' || str_contains($detail, 'Data too long')) {
$code = 'payload_too_large';
$msg = '文件内容过长,无法保存';
$status = 413;
}
}
if ($debug) {
$msg .= ' (' . $e->getMessage() . ')';
}
Json::fail($code, $msg, $status);
}
}
+100 -28
View File
@@ -6,12 +6,16 @@ namespace Soon\Api\Services;
use Soon\Api\Core\Config;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
use PDOException;
use Throwable;
/**
* .soon 文件服务:JSON 数据流模型,乐观锁、配额、软删除。
*/
final class FileService
{
private const MAX_JSON_BYTES = 33554432;
public static function usersRoot(): string
{
$dir = (string)Config::get('storage.users_dir', SOON_SERVER_ROOT . '/storage/users');
@@ -74,32 +78,40 @@ final class FileService
public static function create(int $userId, string $name, string $json): array
{
$maxFiles = MembershipService::maxFilesLimit($userId);
if (self::fileCount($userId) >= $maxFiles) {
Json::fail('file_limit_exceeded', '已达文件数量上限(' . $maxFiles . ' 个),请清理文件或续订', 413);
$name = self::normalizeName($name);
self::assertJsonPayload($json);
try {
$maxFiles = MembershipService::maxFilesLimit($userId);
if (self::fileCount($userId) >= $maxFiles) {
Json::fail('file_limit_exceeded', '已达文件数量上限(' . $maxFiles . ' 个),请清理文件或续订', 413);
}
$size = strlen($json);
$quota = self::quotaBytes($userId);
$used = self::usedBytes($userId);
if ($quota > 0 && $used + $size > $quota) {
Json::fail('quota_exceeded', '存储空间已满,请清理文件或续订', 413);
}
$now = date('Y-m-d H:i:s');
$thumb = self::extractThumb($json);
$stmt = Db::pdo()->prepare(
'INSERT INTO soon_files (user_id, name, json, size, version, thumb, created_at, updated_at) '
. 'VALUES (:u, :n, :j, :s, 1, :th, :created_at, :updated_at)'
);
$stmt->execute([
'u' => $userId, 'n' => $name, 'j' => $json, 's' => $size, 'th' => $thumb !== '' ? $thumb : null,
'created_at' => $now, 'updated_at' => $now,
]);
$id = (int)Db::pdo()->lastInsertId();
return ['id' => $id, 'name' => $name, 'size' => $size, 'version' => 1, 'updated_at' => $now];
} catch (PDOException $e) {
self::failDb($e);
}
$size = strlen($json);
$quota = self::quotaBytes($userId);
$used = self::usedBytes($userId);
if ($quota > 0 && $used + $size > $quota) {
Json::fail('quota_exceeded', '存储空间已满,请清理文件或续订', 413);
}
$now = date('Y-m-d H:i:s');
$thumb = TemplateService::thumbFromSoonJson($json);
$stmt = Db::pdo()->prepare(
'INSERT INTO soon_files (user_id, name, json, size, version, thumb, created_at, updated_at) '
. 'VALUES (:u, :n, :j, :s, 1, :th, :created_at, :updated_at)'
);
$stmt->execute([
'u' => $userId, 'n' => $name, 'j' => $json, 's' => $size, 'th' => $thumb !== '' ? $thumb : null,
'created_at' => $now, 'updated_at' => $now,
]);
$id = (int)Db::pdo()->lastInsertId();
return ['id' => $id, 'name' => $name, 'size' => $size, 'version' => 1, 'updated_at' => $now];
}
public static function update(int $userId, int $id, string $name, string $json, ?int $expectedVersion): array
{
$name = self::normalizeName($name);
self::assertJsonPayload($json);
$newSize = strlen($json);
$quota = self::quotaBytes($userId);
$used = self::usedBytes($userId);
@@ -121,7 +133,7 @@ final class FileService
}
$now = date('Y-m-d H:i:s');
$newVersion = (int)$row['version'] + 1;
$thumb = TemplateService::thumbFromSoonJson($json);
$thumb = self::extractThumb($json);
$upd = $pdo->prepare(
'UPDATE soon_files SET name = :n, json = :j, size = :s, version = :v, thumb = :th, updated_at = :ts '
. 'WHERE id = :id AND version = :cv'
@@ -137,17 +149,77 @@ final class FileService
$pdo->commit();
return ['id' => $id, 'name' => $name, 'size' => $newSize, 'version' => $newVersion, 'updated_at' => $now];
} catch (\RuntimeException $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
if ($e->getMessage() === 'not_found') Json::fail('not_found', '文件不存在', 404);
if ($e->getMessage() === 'version_conflict') Json::fail('conflict', '版本冲突,请刷新后重试', 409);
if ($e->getMessage() === 'quota_exceeded') Json::fail('quota_exceeded', '存储空间已满', 413);
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
if ($e->getMessage() === 'not_found') {
Json::fail('not_found', '文件不存在', 404);
}
if ($e->getMessage() === 'version_conflict') {
Json::fail('conflict', '版本冲突,请刷新后重试', 409);
}
if ($e->getMessage() === 'quota_exceeded') {
Json::fail('quota_exceeded', '存储空间已满', 413);
}
throw $e;
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
} catch (PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
self::failDb($e);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
private static function normalizeName(string $name): string
{
$name = trim($name);
if ($name === '') {
return 'untitled.soon';
}
if (mb_strlen($name) > 190) {
Json::fail('bad_request', '文件名过长', 400);
}
return $name;
}
private static function assertJsonPayload(string $json): void
{
if ($json === '') {
Json::fail('bad_request', '文件内容为空', 400);
}
$size = strlen($json);
if ($size > self::MAX_JSON_BYTES) {
Json::fail('payload_too_large', '文件内容过大(最大 32MB', 413);
}
}
private static function extractThumb(string $json): string
{
try {
return TemplateService::thumbFromSoonJson($json);
} catch (Throwable) {
return '';
}
}
/** @return never */
private static function failDb(PDOException $e): void
{
$detail = $e->getMessage();
if (str_contains($detail, 'max_allowed_packet') || str_contains($detail, 'Packet too large')) {
Json::fail('payload_too_large', '文件过大,超出服务器数据库限制', 413);
}
if (str_contains($detail, 'Unknown column') || str_contains($detail, "doesn't exist")) {
Json::fail('server_error', '数据库结构不完整,请导入 database/schema.sql 或执行 php scripts/migrate-schema.php', 500);
}
throw $e;
}
public static function softDelete(int $userId, int $id): void
{
$stmt = Db::pdo()->prepare('UPDATE soon_files SET deleted_at = :ts WHERE id = :id AND user_id = :u');
+2
View File
@@ -96,3 +96,5 @@ require SOON_API_ROOT . '/Services/PaymentConfigLoader.php';
require SOON_API_ROOT . '/Services/SettingsConfigLoader.php';
\Soon\Api\Services\PaymentConfigLoader::reload($config);
\Soon\Api\Core\Config::init($config);
require SOON_API_ROOT . '/Core/ErrorHandler.php';
\Soon\Api\Core\ErrorHandler::register();