永久会员与模板库后台化:预览门控、轻量首页与 Admin CRUD
- 会员改为永久激活方案;云端文件不再拦截非会员;预览弹窗内导出/打印才校验 - 首页最近文件支持本地记录,登录后与云端合并;移除独立会员页与订阅页 - 模板库入库管理:soon_templates 表、Admin 上传 CRUD、/templates 轻量列表与按需下载 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -27,6 +27,7 @@ return [
|
||||
'storage' => [
|
||||
'users_dir' => '/www/wwwroot/designadmin.cardsoon.com/backend/storage/users',
|
||||
'models_dir' => '/www/wwwroot/designadmin.cardsoon.com/soonModels',
|
||||
'templates_dir' => '/www/wwwroot/designadmin.cardsoon.com/backend/storage/templates',
|
||||
],
|
||||
'limits' => [
|
||||
'free_quota_mb' => 20,
|
||||
|
||||
@@ -9,6 +9,7 @@ use Soon\Api\Admin\Controllers\PaymentController;
|
||||
use Soon\Api\Admin\Controllers\PlansController;
|
||||
use Soon\Api\Admin\Controllers\SettingsController;
|
||||
use Soon\Api\Admin\Controllers\StatsController;
|
||||
use Soon\Api\Admin\Controllers\TemplatesController;
|
||||
use Soon\Api\Admin\Controllers\UsersController;
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Json;
|
||||
@@ -58,6 +59,11 @@ Router::post('/api/admin/orders/{id}/refund/reject', [OrdersController::class, '
|
||||
Router::get('/api/admin/plans', [PlansController::class, 'list']);
|
||||
Router::post('/api/admin/plans', [PlansController::class, 'upsert']);
|
||||
|
||||
Router::get('/api/admin/templates', [TemplatesController::class, 'list']);
|
||||
Router::post('/api/admin/templates', [TemplatesController::class, 'create']);
|
||||
Router::put('/api/admin/templates/{id}', [TemplatesController::class, 'update']);
|
||||
Router::delete('/api/admin/templates/{id}', [TemplatesController::class, 'delete']);
|
||||
|
||||
Router::get('/api/admin/settings', [SettingsController::class, 'list']);
|
||||
Router::post('/api/admin/settings', [SettingsController::class, 'set']);
|
||||
Router::post('/api/admin/settings/batch', [SettingsController::class, 'setBatch']);
|
||||
|
||||
@@ -9,6 +9,7 @@ use Soon\Api\Controllers\PayController;
|
||||
use Soon\Api\Controllers\PlanController;
|
||||
use Soon\Api\Controllers\SettingsController;
|
||||
use Soon\Api\Controllers\SoonModelController;
|
||||
use Soon\Api\Controllers\TemplateController;
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Core\Router;
|
||||
@@ -61,8 +62,11 @@ Router::post('/api/v1/pay/orders/{order_no}/refund-request', [PayController::cla
|
||||
Router::post('/api/v1/pay/alipay/notify', [PayController::class, 'alipayNotify']);
|
||||
Router::post('/api/v1/pay/wechat/notify', [PayController::class, 'wechatNotify']);
|
||||
|
||||
Router::get('/api/v1/templates', [TemplateController::class, 'index']);
|
||||
Router::get('/api/v1/templates/{id}/thumb', [TemplateController::class, 'thumb']);
|
||||
Router::get('/api/v1/templates/{id}/file', [TemplateController::class, 'file']);
|
||||
Router::get('/api/v1/soon-models/files/{name}', [SoonModelController::class, 'download']);
|
||||
Router::get('/api/v1/soon-models', [SoonModelController::class, 'index']);
|
||||
Router::get('/api/v1/soon-models', [TemplateController::class, 'index']);
|
||||
Router::get('/api/v1/settings', [SettingsController::class, 'publicSettings']);
|
||||
|
||||
if (str_starts_with($path, '/api/v1/')) {
|
||||
|
||||
+21
-4
@@ -48,12 +48,14 @@ CREATE TABLE IF NOT EXISTS `plans` (
|
||||
INSERT INTO `plans` (`code`,`name`,`description`,`price_cents`,`quota_mb`,`max_files`,`duration_days`,`features`,`sort_order`,`is_recommended`,`is_active`) VALUES
|
||||
('free','免费版','免费体验设计与编辑',0,2048,200,0,
|
||||
'["设计与编辑工具免费使用"]',0,0,1),
|
||||
('member_lifetime','永久会员','一次激活,永久使用预览交付能力',100,2048,200,0,
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","永久有效"]',10,1,1),
|
||||
('member_monthly','月度订阅','按月灵活使用,随时续订',1999,2048,200,30,
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]',10,1,1),
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]',20,0,0),
|
||||
('member_quarterly','季度订阅','连续三个月,更省心',5299,2048,200,90,
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]',15,0,1),
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]',25,0,0),
|
||||
('member_yearly','年度订阅','全年畅享,性价比更高',19999,2048,200,365,
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]',20,0,1)
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]',30,0,0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name=VALUES(name),
|
||||
description=VALUES(description),
|
||||
@@ -63,7 +65,8 @@ ON DUPLICATE KEY UPDATE
|
||||
duration_days=VALUES(duration_days),
|
||||
features=VALUES(features),
|
||||
sort_order=VALUES(sort_order),
|
||||
is_recommended=VALUES(is_recommended);
|
||||
is_recommended=VALUES(is_recommended),
|
||||
is_active=VALUES(is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `subscriptions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
@@ -114,6 +117,20 @@ CREATE TABLE IF NOT EXISTS `audit_logs` (
|
||||
KEY `admin` (`admin_id`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `soon_templates` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(120) NOT NULL,
|
||||
`type` TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`thumb` MEDIUMTEXT DEFAULT NULL,
|
||||
`file_size` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`sort_order` INT NOT NULL DEFAULT 0,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `active_sort` (`is_active`, `sort_order`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `settings` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`key` VARCHAR(120) NOT NULL,
|
||||
|
||||
@@ -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