Web 端数据交互与 design2 布局修复
统一门户 JSON 契约:新增 GET /templates/{id},模板/云文件/保存/打开走虚拟 key;
layer 保存弹层替代 prompt,本地 .soon 已登录后台 POST 登记;修复保存静默失败与 design2 全宽布局。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -51,4 +51,10 @@ final class TemplatesController
|
||||
AuditService::log($adminId, 'templates.delete', 'soon_templates:' . $id);
|
||||
Json::ok(['id' => $id]);
|
||||
}
|
||||
|
||||
public function thumb(int $adminId, int $id): void
|
||||
{
|
||||
AuditService::log($adminId, 'templates.thumb', 'soon_templates:' . $id);
|
||||
TemplateService::outputThumbAdmin($id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Soon\Api\Controllers;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Middleware\Auth;
|
||||
use Soon\Api\Services\AuthService;
|
||||
use Soon\Api\Services\MembershipService;
|
||||
|
||||
final class AuthController
|
||||
{
|
||||
@@ -38,7 +39,6 @@ final class AuthController
|
||||
public function me(): void
|
||||
{
|
||||
$user = Auth::require();
|
||||
unset($user['password_hash']);
|
||||
Json::ok($user);
|
||||
Json::ok(array_merge($user, MembershipService::membershipSummary((int)$user['id'])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,20 @@ final class FileController
|
||||
Json::ok(FileService::create($u['id'], $name, $json));
|
||||
}
|
||||
|
||||
public function show(int $id): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
$row = FileService::fetch($u['id'], $id);
|
||||
Json::ok([
|
||||
'id' => (int)$row['id'],
|
||||
'name' => (string)$row['name'],
|
||||
'version' => (int)$row['version'],
|
||||
'size' => (int)$row['size'],
|
||||
'updated_at' => $row['updated_at'],
|
||||
'json' => (string)$row['json'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
|
||||
@@ -17,9 +17,6 @@ final class PlanController
|
||||
public function myPlan(): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
Json::ok([
|
||||
'membership' => MembershipService::currentPlan($u['id']),
|
||||
'recent_orders' => MembershipService::recentOrders($u['id']),
|
||||
]);
|
||||
Json::ok(['membership' => MembershipService::currentPlan($u['id'])]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,17 @@ final class TemplateController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
header('Cache-Control: public, max-age=60, must-revalidate');
|
||||
$items = TemplateService::listPublic();
|
||||
Json::ok(['items' => $items, 'total' => count($items)]);
|
||||
}
|
||||
|
||||
public function show(int $id): void
|
||||
{
|
||||
header('Cache-Control: public, max-age=60, must-revalidate');
|
||||
Json::ok(TemplateService::fetchPublicJson($id));
|
||||
}
|
||||
|
||||
public function thumb(int $id): void
|
||||
{
|
||||
TemplateService::outputThumb($id);
|
||||
|
||||
@@ -16,7 +16,7 @@ final class MembershipService
|
||||
$stmt = Db::pdo()->query(
|
||||
'SELECT id, code, name, description, price_cents, quota_mb, max_files, duration_days, '
|
||||
. 'features, sort_order, is_recommended, is_active FROM plans '
|
||||
. 'WHERE is_active = 1 AND code = "member_lifetime" AND price_cents > 0 '
|
||||
. 'WHERE is_active = 1 AND code = \'member_lifetime\' AND price_cents > 0 '
|
||||
. 'ORDER BY sort_order ASC, price_cents ASC'
|
||||
);
|
||||
$items = [];
|
||||
@@ -34,7 +34,7 @@ final class MembershipService
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT p.*, s.id AS subscription_id, s.expires_at AS subscription_expires_at, s.started_at AS subscription_started_at '
|
||||
. 'FROM subscriptions s JOIN plans p ON p.id = s.plan_id '
|
||||
. 'WHERE s.user_id = :u AND s.status = "active" AND s.expires_at > NOW() '
|
||||
. 'WHERE s.user_id = :u AND s.status = \'active\' AND s.expires_at > NOW() '
|
||||
. 'ORDER BY s.expires_at DESC LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['u' => $userId]);
|
||||
@@ -70,7 +70,7 @@ final class MembershipService
|
||||
$memberQuota = (int)Config::get('limits.member_quota_mb', 2048);
|
||||
$memberFiles = (int)Config::get('limits.member_max_files', 200);
|
||||
$freeQuota = (int)Config::get('limits.free_quota_mb', $memberQuota);
|
||||
$freeStmt = $pdo->prepare('SELECT * FROM plans WHERE code = "free" AND is_active = 1 LIMIT 1');
|
||||
$freeStmt = $pdo->prepare('SELECT * FROM plans WHERE code = \'free\' AND is_active = 1 LIMIT 1');
|
||||
$freeStmt->execute();
|
||||
$freeRow = $freeStmt->fetch();
|
||||
if ($freeRow) {
|
||||
@@ -108,13 +108,32 @@ final class MembershipService
|
||||
{
|
||||
$stmt = Db::pdo()->prepare(
|
||||
'SELECT 1 FROM subscriptions s JOIN plans p ON p.id = s.plan_id '
|
||||
. 'WHERE s.user_id = :u AND s.status = "active" AND s.expires_at > NOW() '
|
||||
. 'AND p.code <> "free" AND p.price_cents > 0 LIMIT 1'
|
||||
. 'WHERE s.user_id = :u AND s.status = \'active\' AND s.expires_at > NOW() '
|
||||
. 'AND p.code <> \'free\' AND p.price_cents > 0 LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['u' => $userId]);
|
||||
return (bool)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/** @return array{is_member:bool,tier:string,name:string,subscription:array<string,mixed>} */
|
||||
public static function membershipSummary(int $userId): array
|
||||
{
|
||||
if (self::isActiveMember($userId)) {
|
||||
return [
|
||||
'is_member' => true,
|
||||
'tier' => 'member',
|
||||
'name' => '会员',
|
||||
'subscription' => ['status' => 'active'],
|
||||
];
|
||||
}
|
||||
return [
|
||||
'is_member' => false,
|
||||
'tier' => 'free',
|
||||
'name' => '普通用户',
|
||||
'subscription' => ['status' => 'free'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public static function recentOrders(int $userId, int $limit = 8): array
|
||||
{
|
||||
|
||||
@@ -28,7 +28,7 @@ final class TemplateService
|
||||
public static function listPublic(): array
|
||||
{
|
||||
$stmt = Db::pdo()->query(
|
||||
'SELECT id, name, type FROM soon_templates WHERE is_active = 1 '
|
||||
'SELECT id, name, type, updated_at FROM soon_templates WHERE is_active = 1 '
|
||||
. 'ORDER BY sort_order ASC, id ASC'
|
||||
);
|
||||
$items = [];
|
||||
@@ -37,6 +37,7 @@ final class TemplateService
|
||||
'id' => (int)$row['id'],
|
||||
'name' => (string)$row['name'],
|
||||
'type' => (int)$row['type'],
|
||||
'updated_at' => (string)$row['updated_at'],
|
||||
];
|
||||
}
|
||||
return $items;
|
||||
@@ -93,23 +94,141 @@ final class TemplateService
|
||||
if (!is_array($data)) {
|
||||
Json::fail('bad_request', '模板须为有效的 JSON(.soon)', 400);
|
||||
}
|
||||
return self::metaFromSoonData($data);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data @return array{type:int, thumb:string} */
|
||||
private static function metaFromSoonData(array $data): array
|
||||
{
|
||||
$type = 1;
|
||||
if (!empty($data['soonType']) && (int)$data['soonType'] === 2) {
|
||||
$type = 2;
|
||||
} elseif (!empty($data['backBlackPic'])) {
|
||||
$type = 2;
|
||||
}
|
||||
return ['type' => $type, 'thumb' => self::extractThumb($data)];
|
||||
}
|
||||
|
||||
$thumb = '';
|
||||
if (!empty($data['frontDisplayPic']) && is_string($data['frontDisplayPic'])) {
|
||||
$candidate = trim($data['frontDisplayPic']);
|
||||
if (str_starts_with($candidate, 'data:image/') && strlen($candidate) <= self::THUMB_MAX_BYTES) {
|
||||
$thumb = $candidate;
|
||||
/** @return array{type:int, thumb:string} */
|
||||
private static function parseSoonMeta(string $json): array
|
||||
{
|
||||
$json = trim($json);
|
||||
if ($json === '') {
|
||||
return ['type' => 1, 'thumb' => ''];
|
||||
}
|
||||
$data = json_decode($json, true);
|
||||
if (!is_array($data)) {
|
||||
return ['type' => 1, 'thumb' => ''];
|
||||
}
|
||||
return self::metaFromSoonData($data);
|
||||
}
|
||||
|
||||
public static function ensureThumbStored(int $id): string
|
||||
{
|
||||
$row = self::find($id);
|
||||
if (!$row) {
|
||||
return '';
|
||||
}
|
||||
$thumb = trim((string)($row['thumb'] ?? ''));
|
||||
if ($thumb !== '' && str_starts_with($thumb, 'data:image/')) {
|
||||
return $thumb;
|
||||
}
|
||||
$path = self::filePath($id);
|
||||
if ($path === null) {
|
||||
return '';
|
||||
}
|
||||
$json = @file_get_contents($path);
|
||||
if ($json === false || $json === '') {
|
||||
return '';
|
||||
}
|
||||
$meta = self::parseSoonMeta($json);
|
||||
$thumb = $meta['thumb'];
|
||||
if ($thumb === '') {
|
||||
return '';
|
||||
}
|
||||
Db::pdo()->prepare('UPDATE soon_templates SET thumb = :th, updated_at = :ua WHERE id = :id')
|
||||
->execute(['th' => $thumb, 'ua' => date('Y-m-d H:i:s'), 'id' => $id]);
|
||||
return $thumb;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private static function extractThumb(array $data): string
|
||||
{
|
||||
if (empty($data['frontDisplayPic']) || !is_string($data['frontDisplayPic'])) {
|
||||
return '';
|
||||
}
|
||||
$candidate = trim($data['frontDisplayPic']);
|
||||
if (!str_starts_with($candidate, 'data:image/')) {
|
||||
return '';
|
||||
}
|
||||
if (strlen($candidate) <= self::THUMB_MAX_BYTES) {
|
||||
return $candidate;
|
||||
}
|
||||
return self::compressDataUrl($candidate);
|
||||
}
|
||||
|
||||
private static function compressDataUrl(string $dataUrl): string
|
||||
{
|
||||
if (!preg_match('#^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$#s', $dataUrl, $m)) {
|
||||
return '';
|
||||
}
|
||||
$bin = base64_decode($m[2], true);
|
||||
if ($bin === false || $bin === '') {
|
||||
return '';
|
||||
}
|
||||
if (!function_exists('imagecreatefromstring')) {
|
||||
return '';
|
||||
}
|
||||
$img = @imagecreatefromstring($bin);
|
||||
if ($img === false) {
|
||||
return '';
|
||||
}
|
||||
$w = imagesx($img);
|
||||
$h = imagesy($img);
|
||||
if ($w < 1 || $h < 1) {
|
||||
imagedestroy($img);
|
||||
return '';
|
||||
}
|
||||
$maxW = 360;
|
||||
if ($w > $maxW) {
|
||||
$newH = max(1, (int)round($h * ($maxW / $w)));
|
||||
$scaled = imagescale($img, $maxW, $newH);
|
||||
if ($scaled !== false) {
|
||||
imagedestroy($img);
|
||||
$img = $scaled;
|
||||
}
|
||||
}
|
||||
ob_start();
|
||||
imagejpeg($img, null, 82);
|
||||
imagedestroy($img);
|
||||
$jpeg = ob_get_clean();
|
||||
if ($jpeg === false || $jpeg === '') {
|
||||
return '';
|
||||
}
|
||||
$out = 'data:image/jpeg;base64,' . base64_encode($jpeg);
|
||||
if (strlen($out) > self::THUMB_MAX_BYTES) {
|
||||
return '';
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
return ['type' => $type, 'thumb' => $thumb];
|
||||
private static function emitThumbBinary(string $thumb, string $rev): void
|
||||
{
|
||||
if (!preg_match('#^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$#s', $thumb, $m)) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$bin = base64_decode($m[2], true);
|
||||
if ($bin === false) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
header('Content-Type: ' . $m[1]);
|
||||
header('Cache-Control: public, max-age=300, must-revalidate');
|
||||
header('ETag: "' . md5($rev . ':' . strlen($bin)) . '"');
|
||||
header('Content-Length: ' . strlen($bin));
|
||||
echo $bin;
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function create(
|
||||
@@ -186,6 +305,8 @@ final class TemplateService
|
||||
$thumb = $meta['thumb'];
|
||||
$size = strlen($json);
|
||||
self::writeFile($id, $json);
|
||||
} elseif ($thumb === '') {
|
||||
$thumb = self::ensureThumbStored($id);
|
||||
}
|
||||
|
||||
Db::pdo()->prepare(
|
||||
@@ -232,6 +353,33 @@ final class TemplateService
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function fetchPublicJson(int $id): array
|
||||
{
|
||||
$row = self::find($id);
|
||||
if (!$row || (int)$row['is_active'] !== 1) {
|
||||
Json::fail('not_found', '模板不存在', 404);
|
||||
}
|
||||
$path = self::filePath($id);
|
||||
if ($path === null) {
|
||||
Json::fail('not_found', '模板文件缺失', 404);
|
||||
}
|
||||
$json = @file_get_contents($path);
|
||||
if ($json === false || trim($json) === '') {
|
||||
Json::fail('server_error', '模板内容读取失败', 500);
|
||||
}
|
||||
if (json_decode($json, true) === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||
Json::fail('server_error', '模板 JSON 无效', 500);
|
||||
}
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => (string)$row['name'],
|
||||
'type' => (int)$row['type'],
|
||||
'updated_at' => (string)$row['updated_at'],
|
||||
'json' => $json,
|
||||
];
|
||||
}
|
||||
|
||||
public static function outputThumb(int $id): void
|
||||
{
|
||||
$row = self::find($id);
|
||||
@@ -239,25 +387,35 @@ final class TemplateService
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$thumb = (string)($row['thumb'] ?? '');
|
||||
$thumb = trim((string)($row['thumb'] ?? ''));
|
||||
if ($thumb === '' || !str_starts_with($thumb, 'data:image/')) {
|
||||
$thumb = self::ensureThumbStored($id);
|
||||
}
|
||||
if ($thumb === '') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('#^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$#', $thumb, $m)) {
|
||||
$rev = (string)$row['updated_at'];
|
||||
self::emitThumbBinary($thumb, $rev);
|
||||
}
|
||||
|
||||
public static function outputThumbAdmin(int $id): void
|
||||
{
|
||||
$row = self::find($id);
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$bin = base64_decode($m[2], true);
|
||||
if ($bin === false) {
|
||||
$thumb = trim((string)($row['thumb'] ?? ''));
|
||||
if ($thumb === '' || !str_starts_with($thumb, 'data:image/')) {
|
||||
$thumb = self::ensureThumbStored($id);
|
||||
$row = self::find($id) ?: $row;
|
||||
}
|
||||
if ($thumb === '') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
header('Content-Type: ' . $m[1]);
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('Content-Length: ' . strlen($bin));
|
||||
echo $bin;
|
||||
exit;
|
||||
self::emitThumbBinary($thumb, (string)$row['updated_at']);
|
||||
}
|
||||
|
||||
public static function outputFile(int $id): void
|
||||
@@ -276,7 +434,8 @@ final class TemplateService
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Content-Disposition: inline; filename="' . str_replace('"', '', $name) . '.soon"');
|
||||
header('Cache-Control: public, max-age=300');
|
||||
header('Cache-Control: public, max-age=60, must-revalidate');
|
||||
header('ETag: "' . md5((string)$row['updated_at'] . ':' . (int)$row['file_size']) . '"');
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user