88c6ce8ccc
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
156 lines
6.1 KiB
PHP
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;
|
|
}
|
|
}
|