永久会员与模板库后台化:预览门控、轻量首页与 Admin CRUD

- 会员改为永久激活方案;云端文件不再拦截非会员;预览弹窗内导出/打印才校验
- 首页最近文件支持本地记录,登录后与云端合并;移除独立会员页与订阅页
- 模板库入库管理:soon_templates 表、Admin 上传 CRUD、/templates 轻量列表与按需下载

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24kycj
2026-06-08 23:52:56 +08:00
parent 152228d41f
commit ebe191b06d
45 changed files with 1616 additions and 1259 deletions
@@ -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);
}
}
+1 -1
View File
@@ -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',
+32 -21
View File
@@ -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;
}
+9 -1
View File
@@ -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(
+1 -1
View File
@@ -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);
}
}
}