88c6ce8ccc
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
407 lines
14 KiB
PHP
407 lines
14 KiB
PHP
<?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 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 '
|
|
. '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<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];
|
|
}
|
|
|
|
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<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);
|
|
}
|
|
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;
|
|
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));
|
|
}
|
|
}
|