Web 端数据交互与 design2 布局修复

统一门户 JSON 契约:新增 GET /templates/{id},模板/云文件/保存/打开走虚拟 key;
layer 保存弹层替代 prompt,本地 .soon 已登录后台 POST 登记;修复保存静默失败与 design2 全宽布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24kycj
2026-06-09 01:20:34 +08:00
parent ebe191b06d
commit 877fd278c2
27 changed files with 1232 additions and 326 deletions
@@ -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);
+24 -5
View File
@@ -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
{
+176 -17
View File
@@ -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;
}