永久会员与模板库后台化:预览门控、轻量首页与 Admin CRUD
- 会员改为永久激活方案;云端文件不再拦截非会员;预览弹窗内导出/打印才校验 - 首页最近文件支持本地记录,登录后与云端合并;移除独立会员页与订阅页 - 模板库入库管理:soon_templates 表、Admin 上传 CRUD、/templates 轻量列表与按需下载 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -57,9 +57,13 @@ final class PlansController
|
||||
if ($code !== 'free' && $priceCents <= 0) {
|
||||
Json::fail('bad_request', '付费套餐价格须大于 0', 400);
|
||||
}
|
||||
if ($code !== 'free' && $durationDays <= 0) {
|
||||
$isLifetime = $code === 'member_lifetime';
|
||||
if ($code !== 'free' && !$isLifetime && $durationDays <= 0) {
|
||||
Json::fail('bad_request', '付费套餐须设置有效天数', 400);
|
||||
}
|
||||
if ($isLifetime) {
|
||||
$durationDays = 0;
|
||||
}
|
||||
if ($coreFeatures === []) {
|
||||
Json::fail('bad_request', '请至少填写一条权益说明', 400);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Admin\Controllers;
|
||||
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\AuditService;
|
||||
use Soon\Api\Services\TemplateService;
|
||||
|
||||
final class TemplatesController
|
||||
{
|
||||
public function list(int $adminId): void
|
||||
{
|
||||
AuditService::log($adminId, 'templates.list', 'soon_templates');
|
||||
Json::ok(['items' => TemplateService::listAdmin()]);
|
||||
}
|
||||
|
||||
public function create(int $adminId): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$name = (string)($body['name'] ?? '');
|
||||
$content = (string)($body['content'] ?? '');
|
||||
$sortOrder = (int)($body['sort_order'] ?? 0);
|
||||
$isActive = (int)($body['is_active'] ?? 1) === 1 ? 1 : 0;
|
||||
if ($content === '') {
|
||||
Json::fail('bad_request', 'content 必填(.soon JSON 文本)', 400);
|
||||
}
|
||||
$item = TemplateService::create($name, $content, $sortOrder, $isActive);
|
||||
AuditService::log($adminId, 'templates.create', 'soon_templates:' . $item['id']);
|
||||
Json::ok($item);
|
||||
}
|
||||
|
||||
public function update(int $adminId, int $id): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$name = (string)($body['name'] ?? '');
|
||||
$sortOrder = (int)($body['sort_order'] ?? 0);
|
||||
$isActive = (int)($body['is_active'] ?? 1) === 1 ? 1 : 0;
|
||||
$content = array_key_exists('content', $body) ? (string)$body['content'] : null;
|
||||
if ($content !== null && trim($content) === '') {
|
||||
$content = null;
|
||||
}
|
||||
$item = TemplateService::update($id, $name, $sortOrder, $isActive, $content);
|
||||
AuditService::log($adminId, 'templates.update', 'soon_templates:' . $id);
|
||||
Json::ok($item);
|
||||
}
|
||||
|
||||
public function delete(int $adminId, int $id): void
|
||||
{
|
||||
TemplateService::delete($id);
|
||||
AuditService::log($adminId, 'templates.delete', 'soon_templates:' . $id);
|
||||
Json::ok(['id' => $id]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\AdminPermission;
|
||||
use Soon\Api\Services\AuditService;
|
||||
use Soon\Api\Services\MembershipService;
|
||||
|
||||
final class UsersController
|
||||
{
|
||||
@@ -270,9 +271,9 @@ final class UsersController
|
||||
try {
|
||||
$pdo->prepare('UPDATE subscriptions SET status = \'expired\' WHERE user_id = :u AND status = \'active\'')
|
||||
->execute(['u' => $id]);
|
||||
if ($plan['code'] !== 'free' && $durationDays > 0) {
|
||||
if ($plan['code'] !== 'free') {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$expires = date('Y-m-d H:i:s', time() + $durationDays * 86400);
|
||||
$expires = MembershipService::subscriptionExpiresAt($durationDays);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO subscriptions (user_id, plan_id, status, started_at, expires_at) '
|
||||
. 'VALUES (:u, :p, \'active\', :sa, :ea)'
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace Soon\Api\Controllers;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Middleware\Auth;
|
||||
use Soon\Api\Services\FileService;
|
||||
use Soon\Api\Services\MembershipService;
|
||||
|
||||
final class FileController
|
||||
{
|
||||
@@ -39,7 +38,6 @@ final class FileController
|
||||
public function create(): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
MembershipService::requireActiveMember($u['id']);
|
||||
$body = Json::readBody();
|
||||
$name = (string)($body['name'] ?? 'untitled.soon');
|
||||
$json = (string)($body['json'] ?? '{}');
|
||||
@@ -49,7 +47,6 @@ final class FileController
|
||||
public function update(int $id): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
MembershipService::requireActiveMember($u['id']);
|
||||
$body = Json::readBody();
|
||||
$name = (string)($body['name'] ?? 'untitled.soon');
|
||||
$json = (string)($body['json'] ?? '{}');
|
||||
@@ -67,12 +64,6 @@ final class FileController
|
||||
public function download(int $id): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
$accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
|
||||
$jsonRead = stripos($accept, 'application/json') !== false
|
||||
&& stripos($accept, 'application/octet-stream') === false;
|
||||
if (!$jsonRead) {
|
||||
MembershipService::requireActiveMember($u['id']);
|
||||
}
|
||||
$row = FileService::fetch($u['id'], $id);
|
||||
$name = (string)$row['name'];
|
||||
$json = (string)$row['json'];
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Controllers;
|
||||
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\TemplateService;
|
||||
|
||||
final class TemplateController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$items = TemplateService::listPublic();
|
||||
Json::ok(['items' => $items, 'total' => count($items)]);
|
||||
}
|
||||
|
||||
public function thumb(int $id): void
|
||||
{
|
||||
TemplateService::outputThumb($id);
|
||||
}
|
||||
|
||||
public function file(int $id): void
|
||||
{
|
||||
TemplateService::outputFile($id);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ final class AlipayClient
|
||||
'version' => '1.0',
|
||||
'notify_url' => (string)Config::get('site.base_url', '') . '/api/v1/pay/alipay/notify',
|
||||
'return_url' => (string)Config::get('site.front_base_url', Config::get('site.base_url', ''))
|
||||
. '/pages/member.web.html?paid=' . rawurlencode($orderNo),
|
||||
. '/pages/index.web.html?paid=' . rawurlencode($orderNo),
|
||||
'biz_content' => json_encode([
|
||||
'out_trade_no' => $orderNo,
|
||||
'product_code' => 'FAST_INSTANT_TRADE_PAY',
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace Soon\Api\Services;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
|
||||
/**
|
||||
* 会员与配额服务。
|
||||
@@ -16,7 +15,8 @@ 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 '
|
||||
. 'features, sort_order, is_recommended, is_active FROM plans '
|
||||
. 'WHERE is_active = 1 AND code = "member_lifetime" AND price_cents > 0 '
|
||||
. 'ORDER BY sort_order ASC, price_cents ASC'
|
||||
);
|
||||
$items = [];
|
||||
@@ -42,15 +42,25 @@ final class MembershipService
|
||||
|
||||
if ($row) {
|
||||
$plan = self::enrichPlan($row);
|
||||
$expiresAt = (string)($row['subscription_expires_at'] ?? '');
|
||||
$daysRemaining = self::daysUntil($expiresAt);
|
||||
$plan['subscription'] = [
|
||||
'id' => (int)$row['subscription_id'],
|
||||
'started_at' => $row['subscription_started_at'] ?? null,
|
||||
'expires_at' => $expiresAt,
|
||||
'days_remaining' => $daysRemaining,
|
||||
'status' => $daysRemaining <= 7 ? 'expiring' : 'active',
|
||||
];
|
||||
$durationDays = (int)($row['duration_days'] ?? 0);
|
||||
$startedAt = $row['subscription_started_at'] ?? null;
|
||||
if ($durationDays <= 0 || (string)($row['code'] ?? '') === 'member_lifetime') {
|
||||
$plan['subscription'] = [
|
||||
'type' => 'lifetime',
|
||||
'activated_at' => $startedAt,
|
||||
'status' => 'active',
|
||||
];
|
||||
} else {
|
||||
$expiresAt = (string)($row['subscription_expires_at'] ?? '');
|
||||
$daysRemaining = self::daysUntil($expiresAt);
|
||||
$plan['subscription'] = [
|
||||
'id' => (int)$row['subscription_id'],
|
||||
'started_at' => $startedAt,
|
||||
'expires_at' => $expiresAt,
|
||||
'days_remaining' => $daysRemaining,
|
||||
'status' => $daysRemaining <= 7 ? 'expiring' : 'active',
|
||||
];
|
||||
}
|
||||
$plan['usage'] = $usage;
|
||||
$plan['tier'] = 'member';
|
||||
$plan['is_member'] = true;
|
||||
@@ -69,7 +79,7 @@ final class MembershipService
|
||||
$plan = self::enrichPlan([
|
||||
'id' => 0,
|
||||
'code' => 'free',
|
||||
'name' => '免费版',
|
||||
'name' => '普通用户',
|
||||
'description' => '适合个人体验与轻量设计',
|
||||
'price_cents' => 0,
|
||||
'quota_mb' => $freeQuota,
|
||||
@@ -105,13 +115,6 @@ final class MembershipService
|
||||
return (bool)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public static function requireActiveMember(int $userId): void
|
||||
{
|
||||
if (!self::isActiveMember($userId)) {
|
||||
Json::fail('membership_required', '此功能需要订阅后使用', 403);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public static function recentOrders(int $userId, int $limit = 8): array
|
||||
{
|
||||
@@ -158,9 +161,15 @@ final class MembershipService
|
||||
return ['items' => $items, 'total' => $total, 'page' => $page, 'size' => $size];
|
||||
}
|
||||
|
||||
public static function previewAllowed(int $userId): bool
|
||||
private const LIFETIME_EXPIRES = '9999-12-31 23:59:59';
|
||||
|
||||
public static function subscriptionExpiresAt(int $durationDays, ?int $baseTs = null): string
|
||||
{
|
||||
return self::isActiveMember($userId);
|
||||
if ($durationDays <= 0) {
|
||||
return self::LIFETIME_EXPIRES;
|
||||
}
|
||||
$baseTs = $baseTs ?? time();
|
||||
return date('Y-m-d H:i:s', $baseTs + $durationDays * 86400);
|
||||
}
|
||||
|
||||
public static function settings(): array
|
||||
@@ -255,6 +264,8 @@ final class MembershipService
|
||||
}
|
||||
if ($durationDays > 0) {
|
||||
$core[] = '订阅周期:' . self::periodLabel($durationDays);
|
||||
} elseif ($code === 'member_lifetime' || $durationDays <= 0) {
|
||||
$core[] = '永久有效';
|
||||
}
|
||||
return $core;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ final class PayService
|
||||
|
||||
public static function createOrder(int $userId, int $planId, string $channel, string $clientIp): array
|
||||
{
|
||||
if (MembershipService::isActiveMember($userId)) {
|
||||
Json::fail('already_member', '您已是会员,无需重复激活', 409);
|
||||
}
|
||||
$plan = self::loadActivePlan($planId);
|
||||
self::expireStalePendingOrders($userId);
|
||||
$existing = self::findReusablePendingOrder($userId, $planId, $channel);
|
||||
@@ -239,7 +242,12 @@ final class PayService
|
||||
if ($activeExpires) {
|
||||
$baseTs = max($baseTs, strtotime((string)$activeExpires));
|
||||
}
|
||||
$expires = date('Y-m-d H:i:s', $baseTs + (int)$plan['duration_days'] * 86400);
|
||||
$durationDays = (int)$plan['duration_days'];
|
||||
if ($durationDays <= 0) {
|
||||
$expires = '9999-12-31 23:59:59';
|
||||
} else {
|
||||
$expires = date('Y-m-d H:i:s', $baseTs + $durationDays * 86400);
|
||||
}
|
||||
$pdo->prepare('UPDATE subscriptions SET status = "expired" WHERE user_id = :u AND status = "active"')
|
||||
->execute(['u' => $order['user_id']]);
|
||||
$pdo->prepare(
|
||||
|
||||
@@ -169,7 +169,7 @@ final class PaymentConfig
|
||||
'wechat' => $baseUrl !== '' ? $baseUrl . '/api/v1/pay/wechat/notify' : '',
|
||||
],
|
||||
'return_url_hint' => rtrim((string)Config::get('site.front_base_url', Config::get('site.base_url', '')), '/')
|
||||
. '/pages/member.web.html?paid={order_no}',
|
||||
. '/pages/index.web.html?paid={order_no}',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
|
||||
final class TemplateService
|
||||
{
|
||||
private const THUMB_MAX_BYTES = 524288;
|
||||
|
||||
public static function storageDir(): string
|
||||
{
|
||||
$dir = (string)Config::get('storage.templates_dir', '');
|
||||
if ($dir === '') {
|
||||
$dir = SOON_SERVER_ROOT . '/storage/templates';
|
||||
}
|
||||
$dir = rtrim($dir, '/\\');
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public static function listPublic(): array
|
||||
{
|
||||
$stmt = Db::pdo()->query(
|
||||
'SELECT id, name, type FROM soon_templates WHERE is_active = 1 '
|
||||
. 'ORDER BY sort_order ASC, id ASC'
|
||||
);
|
||||
$items = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$items[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'name' => (string)$row['name'],
|
||||
'type' => (int)$row['type'],
|
||||
];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public static function listAdmin(): array
|
||||
{
|
||||
$stmt = Db::pdo()->query(
|
||||
'SELECT id, name, type, file_size, sort_order, is_active, created_at, updated_at, '
|
||||
. '(CASE WHEN thumb IS NOT NULL AND thumb <> "" THEN 1 ELSE 0 END) AS has_thumb '
|
||||
. 'FROM soon_templates ORDER BY sort_order ASC, id ASC'
|
||||
);
|
||||
$items = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$items[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'name' => (string)$row['name'],
|
||||
'type' => (int)$row['type'],
|
||||
'file_size' => (int)$row['file_size'],
|
||||
'sort_order' => (int)$row['sort_order'],
|
||||
'is_active' => (int)$row['is_active'] === 1,
|
||||
'has_thumb' => (int)$row['has_thumb'] === 1,
|
||||
'created_at' => $row['created_at'],
|
||||
'updated_at' => $row['updated_at'],
|
||||
];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$stmt = Db::pdo()->prepare('SELECT * FROM soon_templates WHERE id = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function filePath(int $id): ?string
|
||||
{
|
||||
$path = self::storageDir() . DIRECTORY_SEPARATOR . $id . '.soon';
|
||||
return is_file($path) && is_readable($path) ? $path : null;
|
||||
}
|
||||
|
||||
/** @return array{type:int, thumb:string} */
|
||||
public static function parseSoonContent(string $json): array
|
||||
{
|
||||
$json = trim($json);
|
||||
if ($json === '') {
|
||||
Json::fail('bad_request', '模板内容为空', 400);
|
||||
}
|
||||
$data = json_decode($json, true);
|
||||
if (!is_array($data)) {
|
||||
Json::fail('bad_request', '模板须为有效的 JSON(.soon)', 400);
|
||||
}
|
||||
|
||||
$type = 1;
|
||||
if (!empty($data['soonType']) && (int)$data['soonType'] === 2) {
|
||||
$type = 2;
|
||||
} elseif (!empty($data['backBlackPic'])) {
|
||||
$type = 2;
|
||||
}
|
||||
|
||||
$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 ['type' => $type, 'thumb' => $thumb];
|
||||
}
|
||||
|
||||
public static function create(
|
||||
string $name,
|
||||
string $json,
|
||||
int $sortOrder = 0,
|
||||
int $isActive = 1
|
||||
): array {
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
Json::fail('bad_request', '名称必填', 400);
|
||||
}
|
||||
if (mb_strlen($name) > 120) {
|
||||
Json::fail('bad_request', '名称过长', 400);
|
||||
}
|
||||
|
||||
$meta = self::parseSoonContent($json);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$pdo = Db::pdo();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO soon_templates (name, type, thumb, file_size, sort_order, is_active, created_at, updated_at) '
|
||||
. 'VALUES (:n, :t, :th, :sz, :so, :a, :ca, :ua)'
|
||||
)->execute([
|
||||
'n' => $name,
|
||||
't' => $meta['type'],
|
||||
'th' => $meta['thumb'],
|
||||
'sz' => strlen($json),
|
||||
'so' => $sortOrder,
|
||||
'a' => $isActive ? 1 : 0,
|
||||
'ca' => $now,
|
||||
'ua' => $now,
|
||||
]);
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
self::writeFile($id, $json);
|
||||
$item = self::find($id);
|
||||
if (!$item) {
|
||||
Json::fail('server_error', '模板创建失败', 500);
|
||||
}
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => (string)$item['name'],
|
||||
'type' => (int)$item['type'],
|
||||
'file_size' => (int)$item['file_size'],
|
||||
'sort_order' => (int)$item['sort_order'],
|
||||
'is_active' => (int)$item['is_active'] === 1,
|
||||
'has_thumb' => trim((string)($item['thumb'] ?? '')) !== '',
|
||||
'created_at' => $item['created_at'],
|
||||
'updated_at' => $item['updated_at'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function update(
|
||||
int $id,
|
||||
string $name,
|
||||
int $sortOrder,
|
||||
int $isActive,
|
||||
?string $json = null
|
||||
): array {
|
||||
$row = self::find($id);
|
||||
if (!$row) {
|
||||
Json::fail('not_found', '模板不存在', 404);
|
||||
}
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
Json::fail('bad_request', '名称必填', 400);
|
||||
}
|
||||
|
||||
$type = (int)$row['type'];
|
||||
$thumb = (string)($row['thumb'] ?? '');
|
||||
$size = (int)$row['file_size'];
|
||||
if ($json !== null && trim($json) !== '') {
|
||||
$meta = self::parseSoonContent($json);
|
||||
$type = $meta['type'];
|
||||
$thumb = $meta['thumb'];
|
||||
$size = strlen($json);
|
||||
self::writeFile($id, $json);
|
||||
}
|
||||
|
||||
Db::pdo()->prepare(
|
||||
'UPDATE soon_templates SET name = :n, type = :t, thumb = :th, file_size = :sz, '
|
||||
. 'sort_order = :so, is_active = :a, updated_at = :ua WHERE id = :id'
|
||||
)->execute([
|
||||
'n' => $name,
|
||||
't' => $type,
|
||||
'th' => $thumb,
|
||||
'sz' => $size,
|
||||
'so' => $sortOrder,
|
||||
'a' => $isActive ? 1 : 0,
|
||||
'ua' => date('Y-m-d H:i:s'),
|
||||
'id' => $id,
|
||||
]);
|
||||
|
||||
$item = self::find($id);
|
||||
if (!$item) {
|
||||
Json::fail('server_error', '模板更新失败', 500);
|
||||
}
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => (string)$item['name'],
|
||||
'type' => (int)$item['type'],
|
||||
'file_size' => (int)$item['file_size'],
|
||||
'sort_order' => (int)$item['sort_order'],
|
||||
'is_active' => (int)$item['is_active'] === 1,
|
||||
'has_thumb' => trim((string)($item['thumb'] ?? '')) !== '',
|
||||
'created_at' => $item['created_at'],
|
||||
'updated_at' => $item['updated_at'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function delete(int $id): void
|
||||
{
|
||||
$row = self::find($id);
|
||||
if (!$row) {
|
||||
Json::fail('not_found', '模板不存在', 404);
|
||||
}
|
||||
Db::pdo()->prepare('DELETE FROM soon_templates WHERE id = :id')->execute(['id' => $id]);
|
||||
$path = self::storageDir() . DIRECTORY_SEPARATOR . $id . '.soon';
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public static function outputThumb(int $id): void
|
||||
{
|
||||
$row = self::find($id);
|
||||
if (!$row || (int)$row['is_active'] !== 1) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$thumb = (string)($row['thumb'] ?? '');
|
||||
if ($thumb === '' || !str_starts_with($thumb, 'data:image/')) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('#^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$#', $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=86400');
|
||||
header('Content-Length: ' . strlen($bin));
|
||||
echo $bin;
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function outputFile(int $id): void
|
||||
{
|
||||
$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);
|
||||
}
|
||||
$name = preg_replace('/[^\w\x{4e00}-\x{9fff}\-.]+/u', '_', (string)$row['name']);
|
||||
if ($name === '') {
|
||||
$name = 'template';
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Content-Disposition: inline; filename="' . str_replace('"', '', $name) . '.soon"');
|
||||
header('Cache-Control: public, max-age=300');
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
|
||||
private static function writeFile(int $id, string $json): void
|
||||
{
|
||||
$path = self::storageDir() . DIRECTORY_SEPARATOR . $id . '.soon';
|
||||
if (@file_put_contents($path, $json, LOCK_EX) === false) {
|
||||
Db::pdo()->prepare('DELETE FROM soon_templates WHERE id = :id')->execute(['id' => $id]);
|
||||
Json::fail('server_error', '模板文件写入失败', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user