Files
SoonDesign/backend-web/src/Services/MembershipService.php
T
24kycj 4ce4486b26 Web 端本地缓存、首页与会员弹窗体验优化
新增 IndexedDB 与最近文件 L1 缓存、云保存/打开链路;首页支持下载与清空本地缓存;修复清空 storage 后语言初始化报错;优化 design2 背景加载与侧栏标签样式;完善会员激活/支付弹层样式;后端补充文件缩略图字段与 thumb 接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 11:05:44 +08:00

437 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Config;
use Soon\Api\Core\Db;
/**
* 会员与配额服务。
*/
final class MembershipService
{
public static function plans(): array
{
$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 '
. 'ORDER BY sort_order ASC, price_cents ASC'
);
$items = [];
foreach ($stmt->fetchAll() as $row) {
$items[] = self::enrichPlan($row);
}
return $items;
}
public static function currentPlan(int $userId): array
{
$pdo = Db::pdo();
$usage = self::usageStats($userId);
$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() '
. 'ORDER BY s.expires_at DESC LIMIT 1'
);
$stmt->execute(['u' => $userId]);
$row = $stmt->fetch();
if ($row) {
$plan = self::enrichPlan($row);
$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;
return $plan;
}
$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->execute();
$freeRow = $freeStmt->fetch();
if ($freeRow) {
$plan = self::enrichPlan($freeRow);
} else {
$plan = self::enrichPlan([
'id' => 0,
'code' => 'free',
'name' => '普通用户',
'description' => '适合个人体验与轻量设计',
'price_cents' => 0,
'quota_mb' => $freeQuota,
'max_files' => $memberFiles,
'duration_days' => 0,
'features' => '[]',
'sort_order' => 0,
'is_recommended' => 0,
'is_active' => 1,
]);
}
$plan['subscription'] = [
'id' => null,
'started_at' => null,
'expires_at' => null,
'days_remaining' => null,
'status' => 'free',
];
$plan['usage'] = $usage;
$plan['tier'] = 'free';
$plan['is_member'] = false;
return $plan;
}
public static function isActiveMember(int $userId): bool
{
$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'
);
$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
{
return self::listOrders($userId, 1, $limit)['items'];
}
/**
* @return array{items: array<int, array<string, mixed>>, total: int, page: int, size: int}
*/
public static function listOrders(int $userId, int $page = 1, int $size = 8): array
{
$page = max(1, $page);
$size = max(1, min(50, $size));
$offset = ($page - 1) * $size;
$pdo = Db::pdo();
$countStmt = $pdo->prepare('SELECT COUNT(*) FROM pay_orders WHERE user_id = :u');
$countStmt->execute(['u' => $userId]);
$total = (int)$countStmt->fetchColumn();
$stmt = $pdo->prepare(
'SELECT o.order_no, o.plan_id, o.amount_cents, o.status, o.channel, o.refund_status, '
. 'o.created_at, o.paid_at, p.name AS plan_name, p.code AS plan_code '
. 'FROM pay_orders o JOIN plans p ON p.id = o.plan_id '
. 'WHERE o.user_id = :u ORDER BY o.id DESC LIMIT :lim OFFSET :off'
);
$stmt->bindValue('u', $userId, \PDO::PARAM_INT);
$stmt->bindValue('lim', $size, \PDO::PARAM_INT);
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
$stmt->execute();
$items = [];
foreach ($stmt->fetchAll() as $row) {
$items[] = [
'order_no' => $row['order_no'],
'plan_id' => (int)$row['plan_id'],
'plan_name' => $row['plan_name'],
'plan_code' => $row['plan_code'],
'amount_cents' => (int)$row['amount_cents'],
'status' => $row['status'],
'refund_status' => $row['refund_status'] ?? 'none',
'channel' => $row['channel'],
'created_at' => $row['created_at'],
'paid_at' => $row['paid_at'],
];
}
return ['items' => $items, 'total' => $total, 'page' => $page, 'size' => $size];
}
private const LIFETIME_EXPIRES = '9999-12-31 23:59:59';
public static function subscriptionExpiresAt(int $durationDays, ?int $baseTs = null): string
{
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
{
$stmt = Db::pdo()->query('SELECT `key`, `value` FROM settings');
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[$row['key']] = $row['value'];
}
return $out;
}
public static function maxFilesLimit(int $userId): int
{
$plan = self::currentPlan($userId);
$limit = (int)($plan['max_files'] ?? 0);
return $limit > 0 ? $limit : 10;
}
/** @param array<string, mixed> $row */
public static function enrichPlanForAdmin(array $row): array
{
$plan = self::enrichPlan($row);
$plan['is_active'] = (int)($row['is_active'] ?? 1);
return $plan;
}
/**
* 由套餐数值字段生成权益说明(与 FileService / 订阅逻辑一致,可执行)。
* @param array<string, mixed> $row
* @return list<string>
*/
/** @return list<string> */
public static function sanitizeFeatureLines(mixed $input): array
{
if (!is_array($input)) {
return [];
}
$out = [];
foreach ($input as $line) {
if (!is_string($line)) {
continue;
}
$t = trim($line);
if ($t === '') {
continue;
}
if (mb_strlen($t) > 200) {
$t = mb_substr($t, 0, 200);
}
$out[] = $t;
}
return array_values(array_unique($out));
}
/** @return list<string>|null */
public static function parseStoredFeatures(mixed $raw): ?array
{
if (is_array($raw)) {
$lines = self::sanitizeFeatureLines($raw);
return $lines === [] ? null : $lines;
}
if (!is_string($raw) || trim($raw) === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
$lines = self::sanitizeFeatureLines($decoded);
return $lines === [] ? null : $lines;
}
/**
* @param list<string> $coreLines
* @return list<string>
*/
public static function buildPlanFeatures(string $code, int $durationDays, array $coreLines): array
{
$core = self::sanitizeFeatureLines($coreLines);
if ($code === 'free') {
return $core !== [] ? $core : self::planFeatureLines(['code' => 'free']);
}
$core = array_values(array_filter($core, static function (string $l): bool {
return strpos($l, '订阅有效期') === false
&& strpos($l, '订阅周期') === false
&& strpos($l, '到期后') === false
&& strpos($l, '到期') === false;
}));
if ($core === []) {
$core = self::memberBenefitLines();
}
if ($durationDays > 0) {
$core[] = '订阅周期:' . self::periodLabel($durationDays);
} elseif ($code === 'member_lifetime' || $durationDays <= 0) {
$core[] = '永久有效';
}
return $core;
}
public static function planFeatureLines(array $row): array
{
$code = (string)($row['code'] ?? '');
if ($code === 'free') {
return ['设计与编辑工具免费使用'];
}
$days = (int)($row['duration_days'] ?? 0);
return self::buildPlanFeatures($code, $days, self::memberBenefitLines());
}
public static function resolveFeatures(array $row): array
{
$stored = self::parseStoredFeatures($row['features'] ?? null);
if ($stored !== null) {
return $stored;
}
return self::planFeatureLines($row);
}
/** @return list<string> */
public static function memberBenefitLines(): array
{
return [
'高清预览',
'成品打印',
'云端保存',
'导出设计文件',
];
}
/** @return list<string> */
public static function coreFeatureLines(array $features): array
{
return array_values(array_filter($features, static function (string $l): bool {
return strpos($l, '订阅有效期') === false
&& strpos($l, '订阅周期') === false
&& strpos($l, '到期后') === false
&& strpos($l, '到期') === false;
}));
}
/** @param array<string, mixed> $row */
private static function enrichPlan(array $row): array
{
$quotaMb = (int)($row['quota_mb'] ?? 0);
$durationDays = (int)($row['duration_days'] ?? 0);
$priceCents = (int)($row['price_cents'] ?? 0);
$maxFiles = (int)($row['max_files'] ?? 0);
return [
'id' => (int)($row['id'] ?? 0),
'code' => (string)($row['code'] ?? ''),
'name' => (string)($row['name'] ?? ''),
'description' => (string)($row['description'] ?? ''),
'price_cents' => $priceCents,
'price_display' => number_format($priceCents / 100, 2, '.', ''),
'quota_mb' => $quotaMb,
'quota_display' => self::formatQuota($quotaMb),
'max_files' => $maxFiles,
'duration_days' => $durationDays,
'period_label' => self::periodLabel($durationDays),
'features' => self::resolveFeatures($row),
'limits' => [
'quota_mb' => $quotaMb,
'max_files' => $maxFiles,
'duration_days' => $durationDays,
],
'sort_order' => (int)($row['sort_order'] ?? 0),
'is_recommended' => (int)($row['is_recommended'] ?? 0) === 1,
'is_active' => (int)($row['is_active'] ?? 1) === 1,
];
}
/** @return array<string, int|float> */
private static function usageStats(int $userId): array
{
$pdo = Db::pdo();
$fileStmt = $pdo->prepare(
'SELECT COUNT(*) AS files_count, COALESCE(SUM(size), 0) AS storage_bytes '
. 'FROM soon_files WHERE user_id = :u AND deleted_at IS NULL'
);
$fileStmt->execute(['u' => $userId]);
$row = $fileStmt->fetch() ?: ['files_count' => 0, 'storage_bytes' => 0];
$bytes = (int)$row['storage_bytes'];
$usedMb = $bytes > 0 ? round($bytes / 1024 / 1024, 2) : 0.0;
return [
'files_count' => (int)$row['files_count'],
'storage_bytes' => $bytes,
'used_mb' => $usedMb,
'used_display' => $bytes > 0 ? self::formatBytes($bytes) : '0 MB',
];
}
private static function formatQuota(int $mb): string
{
if ($mb <= 0) {
return '0 MB';
}
if ($mb >= 1024) {
$gb = $mb / 1024;
return ($gb >= 10 ? (string)(int)round($gb) : rtrim(rtrim(number_format($gb, 1, '.', ''), '0'), '.')) . ' GB';
}
return $mb . ' MB';
}
private static function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . ' B';
}
if ($bytes < 1024 * 1024) {
return round($bytes / 1024, 1) . ' KB';
}
return round($bytes / (1024 * 1024), 2) . ' MB';
}
private static function periodLabel(int $days): string
{
if ($days <= 0) {
return '永久有效';
}
if ($days >= 365) {
return '1 年';
}
if ($days === 90) {
return '1 季';
}
if ($days >= 30 && $days % 30 === 0) {
$months = (int)($days / 30);
return $months . ' 个月';
}
return $days . ' 天';
}
private static function daysUntil(string $expiresAt): ?int
{
if ($expiresAt === '') {
return null;
}
$ts = strtotime($expiresAt);
if ($ts === false) {
return null;
}
return max(0, (int)ceil(($ts - time()) / 86400));
}
}