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 ' . '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); $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', ]; $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(); } public static function requireActiveMember(int $userId): void { if (!self::isActiveMember($userId)) { Json::fail('membership_required', '此功能需要订阅后使用', 403); } } /** @return array> */ public static function recentOrders(int $userId, int $limit = 8): array { return self::listOrders($userId, 1, $limit)['items']; } /** * @return array{items: array>, 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]; } public static function previewAllowed(int $userId): bool { return self::isActiveMember($userId); } 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 $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 $row * @return list */ /** @return list */ 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|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 $coreLines * @return list */ 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); } 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 */ public static function memberBenefitLines(): array { return [ '高清预览', '成品打印', '云端保存', '导出设计文件', ]; } /** @return list */ 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 $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 */ 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; return [ 'files_count' => (int)$row['files_count'], 'storage_bytes' => $bytes, 'used_mb' => $usedMb, 'used_display' => self::formatQuota(max(1, $usedMb)) !== '0 MB' ? 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)); } }