Files
SoonDesign/backend-web/src/Services/FileService.php
T
24kycj 88c6ce8ccc 重构 monorepo 并完善网页端订阅与首页体验
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构
- 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐
- 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作
- 新增管理后台、支付与云端文件 API,更新 README 与项目规范

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 18:17:39 +08:00

156 lines
6.1 KiB
PHP

<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Config;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
/**
* .soon 文件服务:JSON 数据流模型,乐观锁、配额、软删除。
*/
final class FileService
{
public static function usersRoot(): string
{
$dir = (string)Config::get('storage.users_dir', SOON_SERVER_ROOT . '/storage/users');
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
return $dir;
}
public static function userDir(int $userId): string
{
$root = self::usersRoot();
$dir = $root . '/' . $userId;
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
return $dir;
}
public static function quotaBytes(int $userId): int
{
$plan = MembershipService::currentPlan($userId);
$planQuota = (int)($plan['quota_mb'] ?? 50);
return $planQuota * 1024 * 1024;
}
public static function usedBytes(int $userId): int
{
$stmt = Db::pdo()->prepare('SELECT COALESCE(SUM(size),0) AS s FROM soon_files WHERE user_id = :u AND deleted_at IS NULL');
$stmt->execute(['u' => $userId]);
return (int)$stmt->fetchColumn();
}
public static function fileCount(int $userId): int
{
$stmt = Db::pdo()->prepare('SELECT COUNT(*) FROM soon_files WHERE user_id = :u AND deleted_at IS NULL');
$stmt->execute(['u' => $userId]);
return (int)$stmt->fetchColumn();
}
public static function list(int $userId, int $limit, int $offset): array
{
$stmt = Db::pdo()->prepare(
'SELECT id, name, size, version, updated_at, created_at '
. 'FROM soon_files WHERE user_id = :u AND deleted_at IS NULL '
. 'ORDER BY updated_at DESC LIMIT :lim OFFSET :off'
);
$stmt->bindValue('u', $userId, \PDO::PARAM_INT);
$stmt->bindValue('lim', $limit, \PDO::PARAM_INT);
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
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);
}
$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');
$stmt = Db::pdo()->prepare(
'INSERT INTO soon_files (user_id, name, json, size, version, created_at, updated_at) '
. 'VALUES (:u, :n, :j, :s, 1, :created_at, :updated_at)'
);
$stmt->execute(['u' => $userId, 'n' => $name, 'j' => $json, 's' => $size, '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
{
$newSize = strlen($json);
$quota = self::quotaBytes($userId);
$used = self::usedBytes($userId);
$pdo = Db::pdo();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT * FROM soon_files WHERE id = :id AND user_id = :u AND deleted_at IS NULL FOR UPDATE');
$stmt->execute(['id' => $id, 'u' => $userId]);
$row = $stmt->fetch();
if (!$row) {
throw new \RuntimeException('not_found');
}
if ($expectedVersion !== null && (int)$row['version'] !== $expectedVersion) {
throw new \RuntimeException('version_conflict');
}
$oldSize = (int)$row['size'];
if ($quota > 0 && ($used - $oldSize + $newSize) > $quota) {
throw new \RuntimeException('quota_exceeded');
}
$now = date('Y-m-d H:i:s');
$newVersion = (int)$row['version'] + 1;
$upd = $pdo->prepare(
'UPDATE soon_files SET name = :n, json = :j, size = :s, version = :v, updated_at = :ts '
. 'WHERE id = :id AND version = :cv'
);
$upd->execute([
'n' => $name, 'j' => $json, 's' => $newSize, 'v' => $newVersion,
'ts' => $now, 'id' => $id, 'cv' => (int)$row['version'],
]);
if ($upd->rowCount() === 0) {
throw new \RuntimeException('version_conflict');
}
$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);
throw $e;
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
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');
$stmt->execute(['ts' => date('Y-m-d H:i:s'), 'id' => $id, 'u' => $userId]);
}
public static function fetch(int $userId, int $id): array
{
$stmt = Db::pdo()->prepare('SELECT * FROM soon_files WHERE id = :id AND user_id = :u AND deleted_at IS NULL');
$stmt->execute(['id' => $id, 'u' => $userId]);
$row = $stmt->fetch();
if (!$row) {
Json::fail('not_found', '文件不存在', 404);
}
return $row;
}
}