重构 monorepo 并完善网页端订阅与首页体验

- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构
- 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐
- 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作
- 新增管理后台、支付与云端文件 API,更新 README 与项目规范

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24kycj
2026-06-08 18:17:39 +08:00
parent 5814b7bc0e
commit 88c6ce8ccc
511 changed files with 189528 additions and 22804 deletions
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Admin\Controllers;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
final class AuditsController
{
/** @return array{where: string, params: array<string, mixed>} */
private function buildFilters(): array
{
$where = '1=1';
$params = [];
$q = trim((string)($_GET['q'] ?? ''));
if ($q !== '') {
$like = '%' . $q . '%';
$where .= ' AND (a.action LIKE :q_action OR a.target LIKE :q_target OR u.email LIKE :q_email)';
$params['q_action'] = $like;
$params['q_target'] = $like;
$params['q_email'] = $like;
}
$action = trim((string)($_GET['action'] ?? ''));
if ($action !== '') {
$where .= ' AND a.action = :action';
$params['action'] = $action;
}
$from = trim((string)($_GET['from'] ?? ''));
if ($from !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
$where .= ' AND a.created_at >= :from_dt';
$params['from_dt'] = $from . ' 00:00:00';
}
$to = trim((string)($_GET['to'] ?? ''));
if ($to !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
$where .= ' AND a.created_at <= :to_dt';
$params['to_dt'] = $to . ' 23:59:59';
}
return ['where' => $where, 'params' => $params];
}
public function list(int $adminId): void
{
$page = max(1, (int)($_GET['page'] ?? 1));
$size = max(1, min(200, (int)($_GET['size'] ?? 50)));
$offset = ($page - 1) * $size;
$filters = $this->buildFilters();
$where = $filters['where'];
$params = $filters['params'];
$pdo = Db::pdo();
$sql = 'SELECT a.*, u.email AS admin_email FROM audit_logs a '
. 'LEFT JOIN users u ON u.id = a.admin_id '
. 'WHERE ' . $where . ' ORDER BY a.id DESC LIMIT :lim OFFSET :off';
$stmt = $pdo->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v);
}
$stmt->bindValue('lim', $size, \PDO::PARAM_INT);
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
$stmt->execute();
$countStmt = $pdo->prepare(
'SELECT COUNT(*) FROM audit_logs a LEFT JOIN users u ON u.id = a.admin_id WHERE ' . $where
);
foreach ($params as $k => $v) {
$countStmt->bindValue($k, $v);
}
$countStmt->execute();
$count = (int)$countStmt->fetchColumn();
Json::ok(['items' => $stmt->fetchAll(), 'total' => $count, 'page' => $page, 'size' => $size]);
}
}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Admin\Controllers;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
use Soon\Api\Services\AuditService;
use Soon\Api\Services\PayService;
final class OrdersController
{
/** @return array{where: string, params: array<string, mixed>} */
private function buildFilters(): array
{
$where = '1=1';
$params = [];
$q = trim((string)($_GET['q'] ?? ''));
if ($q !== '') {
$like = '%' . $q . '%';
$where .= ' AND (u.email LIKE :q_email OR o.order_no LIKE :q_order)';
$params['q_email'] = $like;
$params['q_order'] = $like;
}
$status = trim((string)($_GET['status'] ?? ''));
if (in_array($status, ['pending', 'paid', 'cancelled', 'refunded'], true)) {
$where .= ' AND o.status = :status';
$params['status'] = $status;
}
$refundStatus = trim((string)($_GET['refund_status'] ?? ''));
if (in_array($refundStatus, ['none', 'pending', 'approved', 'rejected'], true)) {
$where .= ' AND o.refund_status = :refund_status';
$params['refund_status'] = $refundStatus;
}
$channel = trim((string)($_GET['channel'] ?? ''));
if (in_array($channel, ['alipay', 'wechat'], true)) {
$where .= ' AND o.channel = :channel';
$params['channel'] = $channel;
}
$from = trim((string)($_GET['from'] ?? ''));
if ($from !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
$where .= ' AND o.created_at >= :from_dt';
$params['from_dt'] = $from . ' 00:00:00';
}
$to = trim((string)($_GET['to'] ?? ''));
if ($to !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
$where .= ' AND o.created_at <= :to_dt';
$params['to_dt'] = $to . ' 23:59:59';
}
return ['where' => $where, 'params' => $params];
}
public function list(int $adminId): void
{
$page = max(1, (int)($_GET['page'] ?? 1));
$size = max(1, min(200, (int)($_GET['size'] ?? 20)));
$offset = ($page - 1) * $size;
$filters = $this->buildFilters();
$where = $filters['where'];
$params = $filters['params'];
$pdo = Db::pdo();
$sql = 'SELECT o.id, o.order_no, o.user_id, o.plan_id, o.channel, o.amount_cents, o.status, '
. 'o.refund_status, o.refund_reason, o.created_at, o.paid_at, o.cancelled_at, o.refunded_at, '
. 'u.email, p.name AS plan_name '
. 'FROM pay_orders o '
. 'LEFT JOIN users u ON u.id = o.user_id '
. 'LEFT JOIN plans p ON p.id = o.plan_id '
. 'WHERE ' . $where . ' ORDER BY o.id DESC LIMIT :lim OFFSET :off';
$stmt = $pdo->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v);
}
$stmt->bindValue('lim', $size, \PDO::PARAM_INT);
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
$stmt->execute();
$countStmt = $pdo->prepare(
'SELECT COUNT(*) FROM pay_orders o LEFT JOIN users u ON u.id = o.user_id WHERE ' . $where
);
foreach ($params as $k => $v) {
$countStmt->bindValue($k, $v);
}
$countStmt->execute();
$count = (int)$countStmt->fetchColumn();
AuditService::log($adminId, 'orders.list', 'pay_orders', ['page' => $page, 'filters' => $params]);
Json::ok(['items' => $stmt->fetchAll(), 'total' => $count, 'page' => $page, 'size' => $size]);
}
public function detail(int $adminId, int $id): void
{
$pdo = Db::pdo();
$stmt = $pdo->prepare(
'SELECT o.*, u.email, p.name AS plan_name, p.code AS plan_code '
. 'FROM pay_orders o '
. 'LEFT JOIN users u ON u.id = o.user_id '
. 'LEFT JOIN plans p ON p.id = o.plan_id '
. 'WHERE o.id = :id'
);
$stmt->execute(['id' => $id]);
$order = $stmt->fetch();
if (!$order) {
Json::fail('not_found', '订单不存在', 404);
}
$subStmt = $pdo->prepare(
'SELECT s.id, s.status, s.started_at, s.expires_at FROM subscriptions s '
. 'WHERE s.source_order_id = :oid ORDER BY s.id DESC LIMIT 1'
);
$subStmt->execute(['oid' => $id]);
$subscription = $subStmt->fetch() ?: null;
AuditService::log($adminId, 'orders.detail', 'pay_order:' . $id);
Json::ok(['order' => $order, 'subscription' => $subscription]);
}
public function cancel(int $adminId, int $id): void
{
if (!PayService::cancelPending($id)) {
Json::fail('bad_request', '仅待支付订单可取消', 400);
}
AuditService::log($adminId, 'orders.cancel', 'pay_order:' . $id);
Json::ok(['id' => $id, 'status' => 'cancelled']);
}
public function refundApprove(int $adminId, int $id): void
{
$body = Json::readBody();
$note = trim((string)($body['note'] ?? ''));
$result = PayService::approveRefund($id, $note !== '' ? $note : null);
AuditService::log($adminId, 'orders.refundApprove', 'pay_order:' . $id, [
'gateway' => $result['gateway'],
'note' => $note,
]);
Json::ok(['id' => $id, 'status' => 'refunded', 'gateway' => $result['gateway']]);
}
public function refundReject(int $adminId, int $id): void
{
$body = Json::readBody();
$note = trim((string)($body['note'] ?? ''));
PayService::rejectRefund($id, $note);
AuditService::log($adminId, 'orders.refundReject', 'pay_order:' . $id, ['note' => $note]);
Json::ok(['id' => $id, 'refund_status' => 'rejected']);
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Admin\Controllers;
use Soon\Api\Admin\Services\PaymentKeyStore;
use Soon\Api\Core\Json;
final class PaymentController
{
public function upload(int $adminId): void
{
$body = Json::readBody();
$channel = (string)($body['channel'] ?? '');
$kind = (string)($body['kind'] ?? '');
$content = (string)($body['content'] ?? '');
if ($content === '') {
Json::fail('bad_request', 'content 必填', 400);
}
Json::ok(PaymentKeyStore::upload($adminId, $channel, $kind, $content));
}
public function status(int $adminId): void
{
Json::ok(PaymentKeyStore::status($adminId));
}
}
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Admin\Controllers;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
use Soon\Api\Services\AuditService;
use Soon\Api\Services\MembershipService;
final class PlansController
{
public function list(int $adminId): void
{
$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 ORDER BY sort_order ASC, price_cents ASC'
);
$items = [];
foreach ($stmt->fetchAll() as $row) {
$plan = MembershipService::enrichPlanForAdmin($row);
$plan['features_editable'] = MembershipService::coreFeatureLines($plan['features']);
$items[] = $plan;
}
AuditService::log($adminId, 'plans.list', 'plans');
Json::ok(['items' => $items]);
}
public function upsert(int $adminId): void
{
$body = Json::readBody();
$code = trim((string)($body['code'] ?? ''));
$name = trim((string)($body['name'] ?? ''));
$description = trim((string)($body['description'] ?? ''));
$priceCents = (int)($body['price_cents'] ?? 0);
$quotaMb = (int)($body['quota_mb'] ?? 0);
$maxFiles = (int)($body['max_files'] ?? 0);
$durationDays = (int)($body['duration_days'] ?? 0);
$sortOrder = (int)($body['sort_order'] ?? 0);
$isRecommended = (int)($body['is_recommended'] ?? 0) === 1 ? 1 : 0;
$isActive = (int)($body['is_active'] ?? 1) === 1 ? 1 : 0;
$syncMemberBenefits = (int)($body['sync_member_benefits'] ?? 0) === 1;
$coreFeatures = MembershipService::sanitizeFeatureLines($body['features'] ?? []);
if ($code === '' || !preg_match('/^[a-z][a-z0-9_]{1,62}$/', $code)) {
Json::fail('bad_request', 'code 须为小写字母开头的 263 位标识', 400);
}
if ($name === '') {
Json::fail('bad_request', 'name 必填', 400);
}
if ($priceCents < 0 || $quotaMb <= 0 || $maxFiles <= 0) {
Json::fail('bad_request', '价格不能为负,配额与文件数须大于 0', 400);
}
if ($code === 'free' && $priceCents > 0) {
Json::fail('bad_request', '免费版价格须为 0', 400);
}
if ($code !== 'free' && $priceCents <= 0) {
Json::fail('bad_request', '付费套餐价格须大于 0', 400);
}
if ($code !== 'free' && $durationDays <= 0) {
Json::fail('bad_request', '付费套餐须设置有效天数', 400);
}
if ($coreFeatures === []) {
Json::fail('bad_request', '请至少填写一条权益说明', 400);
}
$pdo = Db::pdo();
$features = json_encode(
MembershipService::buildPlanFeatures($code, $durationDays, $coreFeatures),
JSON_UNESCAPED_UNICODE
);
if ($isRecommended === 1) {
$pdo->exec('UPDATE plans SET is_recommended = 0');
}
$stmt = $pdo->prepare(
'INSERT INTO plans (code, name, description, price_cents, quota_mb, max_files, duration_days, '
. 'features, sort_order, is_recommended, is_active) '
. 'VALUES (:c, :n, :d, :p, :q, :mf, :dd, :f, :so, :ir, :a) '
. 'ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), '
. 'price_cents=VALUES(price_cents), quota_mb=VALUES(quota_mb), max_files=VALUES(max_files), '
. 'duration_days=VALUES(duration_days), features=VALUES(features), '
. 'sort_order=VALUES(sort_order), is_recommended=VALUES(is_recommended), is_active=VALUES(is_active)'
);
$stmt->execute([
'c' => $code,
'n' => $name,
'd' => $description !== '' ? $description : null,
'p' => $priceCents,
'q' => $quotaMb,
'mf' => $maxFiles,
'dd' => $durationDays,
'f' => $features,
'so' => $sortOrder,
'ir' => $isRecommended,
'a' => $isActive,
]);
if ($syncMemberBenefits && $code !== 'free') {
$paidStmt = $pdo->query(
'SELECT code, duration_days FROM plans WHERE code <> "free" AND price_cents > 0'
);
$syncStmt = $pdo->prepare('UPDATE plans SET features = :f WHERE code = :c');
foreach ($paidStmt->fetchAll() as $paid) {
$paidCode = (string)$paid['code'];
$paidDays = (int)$paid['duration_days'];
$syncStmt->execute([
'f' => json_encode(
MembershipService::buildPlanFeatures($paidCode, $paidDays, $coreFeatures),
JSON_UNESCAPED_UNICODE
),
'c' => $paidCode,
]);
}
}
AuditService::log($adminId, 'plans.upsert', 'plan:' . $code, $body);
Json::ok(['code' => $code]);
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Admin\Controllers;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
use Soon\Api\Services\AuditService;
final class SettingsController
{
public function list(int $adminId): void
{
$stmt = Db::pdo()->query('SELECT `key`, `value`, updated_at FROM settings ORDER BY `key`');
AuditService::log($adminId, 'settings.list', 'settings');
Json::ok(['items' => $stmt->fetchAll()]);
}
public function set(int $adminId): void
{
$body = Json::readBody();
$key = (string)($body['key'] ?? '');
$value = (string)($body['value'] ?? '');
if ($key === '') {
Json::fail('bad_request', 'key 必填', 400);
}
$stmt = Db::pdo()->prepare(
'INSERT INTO settings (`key`, `value`, updated_at) VALUES (:k, :v, :ts) '
. 'ON DUPLICATE KEY UPDATE `value`=VALUES(`value`), updated_at=VALUES(updated_at)'
);
$stmt->execute(['k' => $key, 'v' => $value, 'ts' => date('Y-m-d H:i:s')]);
AuditService::log($adminId, 'settings.set', 'setting:' . $key, ['value' => $value]);
Json::ok(['key' => $key]);
}
public function setBatch(int $adminId): void
{
$body = Json::readBody();
$items = $body['items'] ?? null;
if (!is_array($items) || $items === []) {
Json::fail('bad_request', 'items 须为非空数组', 400);
}
$pdo = Db::pdo();
$stmt = $pdo->prepare(
'INSERT INTO settings (`key`, `value`, updated_at) VALUES (:k, :v, :ts) '
. 'ON DUPLICATE KEY UPDATE `value`=VALUES(`value`), updated_at=VALUES(updated_at)'
);
$saved = [];
$now = date('Y-m-d H:i:s');
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$key = trim((string)($item['key'] ?? ''));
if ($key === '') {
continue;
}
$value = (string)($item['value'] ?? '');
$stmt->execute(['k' => $key, 'v' => $value, 'ts' => $now]);
$saved[] = $key;
}
if ($saved === []) {
Json::fail('bad_request', '无有效配置项', 400);
}
AuditService::log($adminId, 'settings.set_batch', 'settings', ['keys' => $saved]);
Json::ok(['keys' => $saved]);
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Admin\Controllers;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
use Soon\Api\Services\AuditService;
final class StatsController
{
public function summary(int $adminId): void
{
$pdo = Db::pdo();
AuditService::log($adminId, 'stats.summary', 'dashboard');
$recentStmt = $pdo->query(
'SELECT o.id, o.order_no, o.amount_cents, o.status, o.created_at, u.email '
. 'FROM pay_orders o LEFT JOIN users u ON u.id = o.user_id '
. 'ORDER BY o.id DESC LIMIT 5'
);
Json::ok([
'users' => (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(),
'orders' => (int)$pdo->query('SELECT COUNT(*) FROM pay_orders')->fetchColumn(),
'orders_today' => (int)$pdo->query(
"SELECT COUNT(*) FROM pay_orders WHERE DATE(created_at) = CURDATE()"
)->fetchColumn(),
'orders_pending' => (int)$pdo->query(
"SELECT COUNT(*) FROM pay_orders WHERE status = 'pending'"
)->fetchColumn(),
'refunds_pending' => (int)$pdo->query(
"SELECT COUNT(*) FROM pay_orders WHERE refund_status = 'pending'"
)->fetchColumn(),
'active_subscriptions' => (int)$pdo->query(
"SELECT COUNT(*) FROM subscriptions WHERE status = 'active'"
)->fetchColumn(),
'files_total' => (int)$pdo->query(
'SELECT COUNT(*) FROM soon_files WHERE deleted_at IS NULL'
)->fetchColumn(),
'revenue_today_cents' => (int)$pdo->query(
"SELECT COALESCE(SUM(amount_cents), 0) FROM pay_orders "
. "WHERE status = 'paid' AND paid_at IS NOT NULL AND DATE(paid_at) = CURDATE()"
)->fetchColumn(),
'recent_orders' => $recentStmt->fetchAll(),
]);
}
}
@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Admin\Controllers;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
use Soon\Api\Services\AdminPermission;
use Soon\Api\Services\AuditService;
final class UsersController
{
public function list(int $adminId): void
{
$page = max(1, (int)($_GET['page'] ?? 1));
$size = max(1, min(200, (int)($_GET['size'] ?? 20)));
$offset = ($page - 1) * $size;
$q = trim((string)($_GET['q'] ?? ''));
$baseSql = 'SELECT u.id, u.email, u.role, u.admin_level, u.status, u.created_at, '
. '(SELECT p.name FROM subscriptions s JOIN plans p ON p.id = s.plan_id '
. 'WHERE s.user_id = u.id AND s.status = \'active\' AND s.expires_at > NOW() '
. 'ORDER BY s.expires_at DESC LIMIT 1) AS plan_name '
. 'FROM users u';
$pdo = Db::pdo();
if ($q !== '') {
$like = '%' . $q . '%';
$stmt = $pdo->prepare($baseSql . ' WHERE u.email LIKE :q ORDER BY u.id DESC LIMIT :lim OFFSET :off');
$stmt->bindValue('q', $like);
$stmt->bindValue('lim', $size, \PDO::PARAM_INT);
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
$stmt->execute();
$countStmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE email LIKE :q');
$countStmt->execute(['q' => $like]);
$count = (int)$countStmt->fetchColumn();
} else {
$stmt = $pdo->prepare($baseSql . ' ORDER BY u.id DESC LIMIT :lim OFFSET :off');
$stmt->bindValue('lim', $size, \PDO::PARAM_INT);
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
$stmt->execute();
$count = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
}
AuditService::log($adminId, 'users.list', 'users', ['page' => $page, 'size' => $size, 'q' => $q]);
Json::ok(['items' => $stmt->fetchAll(), 'total' => $count, 'page' => $page, 'size' => $size]);
}
public function create(int $adminId): void
{
$body = Json::readBody();
$email = trim((string)($body['email'] ?? ''));
$password = (string)($body['password'] ?? '');
$role = (string)($body['role'] ?? 'user');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
Json::fail('bad_request', '邮箱格式不正确', 400);
}
if (strlen($password) < 8) {
Json::fail('bad_request', '密码至少 8 位', 400);
}
if (!in_array($role, ['user', 'admin'], true)) {
Json::fail('bad_request', 'role 不合法', 400);
}
if ($role === 'admin') {
AdminPermission::requireFull($adminId);
}
$adminLevel = null;
if ($role === 'admin') {
$adminLevel = (string)($body['admin_level'] ?? 'ops');
if (!in_array($adminLevel, ['full', 'ops'], true)) {
Json::fail('bad_request', 'admin_level 不合法', 400);
}
}
$pdo = Db::pdo();
$dup = $pdo->prepare('SELECT id FROM users WHERE email = :e');
$dup->execute(['e' => $email]);
if ($dup->fetch()) {
Json::fail('conflict', '邮箱已存在', 409);
}
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
$stmt = $pdo->prepare(
'INSERT INTO users (email, password_hash, role, admin_level, status, created_at) '
. 'VALUES (:e, :h, :r, :lv, \'active\', :ts)'
);
$stmt->execute([
'e' => $email,
'h' => $hash,
'r' => $role,
'lv' => $adminLevel,
'ts' => date('Y-m-d H:i:s'),
]);
$id = (int)$pdo->lastInsertId();
AuditService::log($adminId, 'users.create', 'user:' . $id, ['email' => $email, 'role' => $role]);
Json::ok(['id' => $id, 'email' => $email, 'role' => $role]);
}
public function detail(int $adminId, int $id): void
{
$pdo = Db::pdo();
$user = self::findUser($pdo, $id);
if ($user === null) {
Json::fail('not_found', '用户不存在', 404);
}
$subStmt = $pdo->prepare(
'SELECT s.id AS subscription_id, s.plan_id, s.status, s.started_at, s.expires_at, p.name AS plan_name, p.code AS plan_code '
. 'FROM subscriptions s JOIN plans p ON p.id = s.plan_id '
. 'WHERE s.user_id = :uid ORDER BY (s.status = \'active\') DESC, s.id DESC LIMIT 1'
);
$subStmt->execute(['uid' => $id]);
$subscription = $subStmt->fetch() ?: null;
$usageStmt = $pdo->prepare(
'SELECT COUNT(*) AS files_count, COALESCE(SUM(size), 0) AS storage_bytes '
. 'FROM soon_files WHERE user_id = :uid AND deleted_at IS NULL'
);
$usageStmt->execute(['uid' => $id]);
$usage = $usageStmt->fetch() ?: ['files_count' => 0, 'storage_bytes' => 0];
$orderStmt = $pdo->prepare(
'SELECT id, order_no, amount_cents, status, channel, created_at FROM pay_orders '
. 'WHERE user_id = :uid ORDER BY id DESC LIMIT 5'
);
$orderStmt->execute(['uid' => $id]);
$recentOrders = $orderStmt->fetchAll();
AuditService::log($adminId, 'users.detail', 'user:' . $id);
Json::ok([
'user' => $user,
'subscription' => $subscription,
'usage' => [
'files_count' => (int)$usage['files_count'],
'storage_bytes' => (int)$usage['storage_bytes'],
],
'recent_orders' => $recentOrders,
]);
}
public function update(int $adminId, int $id): void
{
$pdo = Db::pdo();
$user = self::findUser($pdo, $id);
if ($user === null) {
Json::fail('not_found', '用户不存在', 404);
}
$body = Json::readBody();
$email = array_key_exists('email', $body) ? trim((string)$body['email']) : null;
$role = array_key_exists('role', $body) ? (string)$body['role'] : null;
$adminLevel = array_key_exists('admin_level', $body) ? (string)$body['admin_level'] : null;
if ($email !== null) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
Json::fail('bad_request', '邮箱格式不正确', 400);
}
$dup = $pdo->prepare('SELECT id FROM users WHERE email = :e AND id != :id');
$dup->execute(['e' => $email, 'id' => $id]);
if ($dup->fetch()) {
Json::fail('conflict', '邮箱已被使用', 409);
}
}
if ($role !== null && !in_array($role, ['user', 'admin'], true)) {
Json::fail('bad_request', 'role 不合法', 400);
}
if ($role === 'user' && $user['role'] === 'admin') {
self::assertAdminRemovable($pdo, $id);
}
if ($role === 'admin' || $user['role'] === 'admin' || $adminLevel !== null) {
AdminPermission::requireFull($adminId);
}
if ($adminLevel !== null && !in_array($adminLevel, ['full', 'ops'], true)) {
Json::fail('bad_request', 'admin_level 不合法', 400);
}
if ($role !== null && $role !== 'admin' && $adminLevel !== null) {
Json::fail('bad_request', '仅管理员账号可设置 admin_level', 400);
}
$sets = [];
$params = ['id' => $id];
if ($email !== null) {
$sets[] = 'email = :e';
$params['e'] = $email;
}
if ($role !== null) {
$sets[] = 'role = :r';
$params['r'] = $role;
if ($role === 'user') {
$sets[] = 'admin_level = NULL';
}
}
if ($adminLevel !== null) {
$sets[] = 'admin_level = :lv';
$params['lv'] = $adminLevel;
}
if ($sets === []) {
Json::fail('bad_request', '无更新字段', 400);
}
$pdo->prepare('UPDATE users SET ' . implode(', ', $sets) . ' WHERE id = :id')->execute($params);
AuditService::log($adminId, 'users.update', 'user:' . $id, [
'email' => $email,
'role' => $role,
'admin_level' => $adminLevel,
]);
Json::ok(['id' => $id]);
}
public function delete(int $adminId, int $id): void
{
if ($id === $adminId) {
Json::fail('bad_request', '不能删除自己的账号', 400);
}
$pdo = Db::pdo();
$user = self::findUser($pdo, $id);
if ($user === null) {
Json::fail('not_found', '用户不存在', 404);
}
if ($user['role'] === 'admin') {
AdminPermission::requireFull($adminId);
self::assertAdminRemovable($pdo, $id);
}
$pdo->beginTransaction();
try {
$pdo->prepare('UPDATE soon_files SET deleted_at = :ts WHERE user_id = :u AND deleted_at IS NULL')
->execute(['ts' => date('Y-m-d H:i:s'), 'u' => $id]);
$pdo->prepare('DELETE FROM subscriptions WHERE user_id = :u')->execute(['u' => $id]);
$pdo->prepare('DELETE FROM users WHERE id = :id')->execute(['id' => $id]);
$pdo->commit();
} catch (\Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
AuditService::log($adminId, 'users.delete', 'user:' . $id, ['email' => $user['email']]);
Json::ok(['id' => $id]);
}
public function setPassword(int $adminId, int $id): void
{
$pdo = Db::pdo();
if (self::findUser($pdo, $id) === null) {
Json::fail('not_found', '用户不存在', 404);
}
$password = (string)(Json::readBody()['password'] ?? '');
if (strlen($password) < 8) {
Json::fail('bad_request', '密码至少 8 位', 400);
}
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
$pdo->prepare('UPDATE users SET password_hash = :h WHERE id = :id')->execute(['h' => $hash, 'id' => $id]);
AuditService::log($adminId, 'users.setPassword', 'user:' . $id);
Json::ok(['id' => $id]);
}
public function setSubscription(int $adminId, int $id): void
{
$pdo = Db::pdo();
if (self::findUser($pdo, $id) === null) {
Json::fail('not_found', '用户不存在', 404);
}
$body = Json::readBody();
$planId = (int)($body['plan_id'] ?? 0);
$days = (int)($body['days'] ?? 0);
if ($planId <= 0) {
Json::fail('bad_request', 'plan_id 必填', 400);
}
$planStmt = $pdo->prepare('SELECT * FROM plans WHERE id = :id');
$planStmt->execute(['id' => $planId]);
$plan = $planStmt->fetch();
if (!$plan) {
Json::fail('not_found', '套餐不存在', 404);
}
$durationDays = $days > 0 ? $days : (int)$plan['duration_days'];
$pdo->beginTransaction();
try {
$pdo->prepare('UPDATE subscriptions SET status = \'expired\' WHERE user_id = :u AND status = \'active\'')
->execute(['u' => $id]);
if ($plan['code'] !== 'free' && $durationDays > 0) {
$now = date('Y-m-d H:i:s');
$expires = date('Y-m-d H:i:s', time() + $durationDays * 86400);
$pdo->prepare(
'INSERT INTO subscriptions (user_id, plan_id, status, started_at, expires_at) '
. 'VALUES (:u, :p, \'active\', :sa, :ea)'
)->execute(['u' => $id, 'p' => $planId, 'sa' => $now, 'ea' => $expires]);
}
$pdo->commit();
} catch (\Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
AuditService::log($adminId, 'users.setSubscription', 'user:' . $id, [
'plan_id' => $planId,
'days' => $durationDays,
]);
Json::ok(['id' => $id, 'plan_id' => $planId]);
}
public function setStatus(int $adminId, int $id): void
{
$body = Json::readBody();
$status = (string)($body['status'] ?? '');
if (!in_array($status, ['active', 'disabled'], true)) {
Json::fail('bad_request', 'status 必须为 active 或 disabled', 400);
}
if ($id === $adminId && $status === 'disabled') {
Json::fail('bad_request', '不能停用自己的账号', 400);
}
$pdo = Db::pdo();
$user = self::findUser($pdo, $id);
if ($user === null) {
Json::fail('not_found', '用户不存在', 404);
}
if ($status === 'disabled' && $user['role'] === 'admin') {
self::assertAdminRemovable($pdo, $id);
}
$pdo->prepare('UPDATE users SET status = :s WHERE id = :id')->execute(['s' => $status, 'id' => $id]);
AuditService::log($adminId, 'users.setStatus', 'user:' . $id, ['status' => $status]);
Json::ok(['id' => $id, 'status' => $status]);
}
/** @return array<string, mixed>|null */
private static function findUser(\PDO $pdo, int $id): ?array
{
$stmt = $pdo->prepare('SELECT id, email, role, admin_level, status, created_at FROM users WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row ?: null;
}
private static function assertAdminRemovable(\PDO $pdo, int $excludeId): void
{
$stmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE role = \'admin\' AND status = \'active\' AND id != :id');
$stmt->execute(['id' => $excludeId]);
if ((int)$stmt->fetchColumn() < 1) {
Json::fail('bad_request', '至少保留一名活跃管理员', 400);
}
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Admin\Services;
use Soon\Api\Core\Config;
use Soon\Api\Core\Json;
use Soon\Api\Services\AuditService;
use Soon\Api\Services\PaymentConfigLoader;
/**
* 支付密钥保存到 storage/payment/,不入库。
*/
final class PaymentKeyStore
{
public static function dir(): string
{
$dir = SOON_SERVER_ROOT . '/storage/payment';
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
return $dir;
}
public static function upload(int $adminId, string $channel, string $kind, string $content): array
{
$channel = strtolower($channel);
$kind = strtolower($kind);
if (!in_array($channel, ['alipay', 'wechat'], true)) {
Json::fail('bad_request', 'channel 不合法', 400);
}
$allowed = [
'alipay' => ['private_key', 'public_key'],
'wechat' => ['mch_private_key', 'api_v3_key', 'platform_cert'],
];
if (!isset($allowed[$channel]) || !in_array($kind, $allowed[$channel], true)) {
Json::fail('bad_request', 'kind 不允许', 400);
}
$path = self::dir() . '/' . $channel . '_' . $kind . '.pem';
@file_put_contents($path, $content);
@chmod($path, 0600);
AuditService::log($adminId, 'payment.key.upload', $channel . ':' . $kind, [
'bytes' => strlen($content),
]);
$config = Config::all();
PaymentConfigLoader::merge($config);
Config::init($config);
return ['channel' => $channel, 'kind' => $kind, 'bytes' => strlen($content)];
}
public static function status(int $adminId): array
{
$dir = self::dir();
$items = [];
foreach (glob($dir . '/*.pem') ?: [] as $f) {
$items[] = [
'name' => basename($f),
'bytes' => filesize($f),
'mtime' => date('Y-m-d H:i:s', (int)filemtime($f)),
];
}
AuditService::log($adminId, 'payment.key.status', 'payment');
return ['items' => $items];
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Controllers;
use Soon\Api\Core\Json;
use Soon\Api\Middleware\Auth;
use Soon\Api\Services\AuthService;
final class AuthController
{
public function register(): void
{
$body = Json::readBody();
$email = (string)($body['email'] ?? '');
$password = (string)($body['password'] ?? '');
Json::ok(AuthService::register($email, $password));
}
public function login(): void
{
$body = Json::readBody();
$email = (string)($body['email'] ?? '');
$password = (string)($body['password'] ?? '');
Json::ok(AuthService::login($email, $password));
}
public function refresh(): void
{
$body = Json::readBody();
$token = (string)($body['refresh_token'] ?? '');
if ($token === '') {
Json::fail('bad_request', '缺少 refresh_token', 400);
}
Json::ok(AuthService::refresh($token));
}
public function me(): void
{
$user = Auth::require();
unset($user['password_hash']);
Json::ok($user);
}
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
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
{
public function index(): void
{
$u = Auth::require();
$page = max(1, (int)($_GET['page'] ?? 0));
$size = max(1, min(200, (int)($_GET['size'] ?? 0)));
$limit = $size > 0 ? $size : max(1, min(200, (int)($_GET['limit'] ?? 50)));
$offset = $page > 0 ? ($page - 1) * $limit : max(0, (int)($_GET['offset'] ?? 0));
if ($page > 0) {
$list = FileService::list($u['id'], $limit, $offset);
Json::ok([
'items' => $list,
'total' => FileService::fileCount($u['id']),
'page' => $page,
'size' => $limit,
]);
return;
}
$list = FileService::list($u['id'], $limit, $offset);
Json::ok([
'items' => $list,
'total' => FileService::fileCount($u['id']),
'limit' => $limit,
'offset' => $offset,
]);
}
public function create(): void
{
$u = Auth::require();
MembershipService::requireActiveMember($u['id']);
$body = Json::readBody();
$name = (string)($body['name'] ?? 'untitled.soon');
$json = (string)($body['json'] ?? '{}');
Json::ok(FileService::create($u['id'], $name, $json));
}
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'] ?? '{}');
$version = isset($body['version']) ? (int)$body['version'] : null;
Json::ok(FileService::update($u['id'], $id, $name, $json, $version));
}
public function delete(int $id): void
{
$u = Auth::require();
FileService::softDelete($u['id'], $id);
Json::ok(['id' => $id]);
}
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'];
$size = strlen($json);
$etag = '"' . md5($json) . '"';
if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim($_SERVER['HTTP_IF_NONE_MATCH']) === $etag) {
http_response_code(304);
exit;
}
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . rawurlencode($name) . '"');
header('Content-Length: ' . $size);
header('ETag: ' . $etag);
header('Accept-Ranges: bytes');
header('Cache-Control: private, max-age=0, must-revalidate');
$start = 0;
$end = $size - 1;
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d+)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
$start = (int)$m[1];
if ($m[2] !== '') $end = (int)$m[2];
if ($end >= $size) $end = $size - 1;
http_response_code(206);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
header('Content-Length: ' . ($end - $start + 1));
}
$out = fopen('php://output', 'wb');
fwrite($out, substr($json, $start, $end - $start + 1));
fclose($out);
exit;
}
}
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Controllers;
use Soon\Api\Core\Json;
use Soon\Api\Middleware\Auth;
use Soon\Api\Services\MembershipService;
use Soon\Api\Services\AlipayClient;
use Soon\Api\Services\PayService;
use Soon\Api\Services\WeChatPay\Client as WeChatClient;
final class PayController
{
public function listMyOrders(): void
{
$u = Auth::require();
$page = max(1, (int)($_GET['page'] ?? 1));
$size = max(1, min(50, (int)($_GET['size'] ?? 8)));
Json::ok(MembershipService::listOrders($u['id'], $page, $size));
}
public function createOrder(): void
{
$u = Auth::require();
$body = Json::readBody();
$planId = (int)($body['plan_id'] ?? 0);
$channel = (string)($body['channel'] ?? 'alipay');
if (!in_array($channel, ['alipay', 'wechat'], true)) {
Json::fail('bad_request', '不支持的支付方式', 400);
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
Json::ok(PayService::createOrder($u['id'], $planId, $channel, $ip));
}
public function checkoutOrder(string $orderNo): void
{
$u = Auth::require();
$body = Json::readBody();
$channel = isset($body['channel']) ? (string)$body['channel'] : null;
if ($channel !== null && !in_array($channel, ['alipay', 'wechat'], true)) {
Json::fail('bad_request', '不支持的支付方式', 400);
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
Json::ok(PayService::checkoutOrderForUser($u['id'], $orderNo, $ip, $channel));
}
public function cancelOrder(string $orderNo): void
{
$u = Auth::require();
PayService::cancelPendingForUser($u['id'], $orderNo);
Json::ok(['order_no' => $orderNo, 'status' => 'cancelled']);
}
public function showOrder(string $orderNo): void
{
$u = Auth::require();
$order = PayService::findByOrderNoForUser($orderNo, $u['id']);
if ($order === null) {
Json::fail('not_found', '订单不存在', 404);
}
Json::ok([
'order_no' => $order['order_no'],
'status' => $order['status'],
'amount_cents' => (int)$order['amount_cents'],
'channel' => $order['channel'],
'paid_at' => $order['paid_at'],
'refund_status' => $order['refund_status'] ?? 'none',
'refund_reason' => $order['refund_reason'] ?? null,
]);
}
public function requestRefund(string $orderNo): void
{
$u = Auth::require();
$body = Json::readBody();
$reason = trim((string)($body['reason'] ?? ''));
PayService::requestRefund($u['id'], $orderNo, $reason);
Json::ok(['order_no' => $orderNo, 'refund_status' => 'pending']);
}
public function alipayNotify(): void
{
$raw = file_get_contents('php://input') ?: '';
parse_str($raw, $params);
if (empty($params) && !empty($_POST)) {
$params = $_POST;
}
if (!AlipayClient::verifyNotify($params)) {
http_response_code(400);
echo 'fail';
exit;
}
$status = (string)($params['trade_status'] ?? '');
if (!in_array($status, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) {
echo 'success';
exit;
}
$orderNo = (string)($params['out_trade_no'] ?? '');
$tradeNo = (string)($params['trade_no'] ?? '');
$amountCents = (int)round((float)($params['total_amount'] ?? 0) * 100);
if (PayService::markPaid($orderNo, 'alipay', $tradeNo, $amountCents)) {
echo 'success';
} else {
echo 'fail';
}
exit;
}
public function wechatNotify(): void
{
$body = file_get_contents('php://input') ?: '';
$signature = $_SERVER['HTTP_WEIXINPAY_SIGNATURE'] ?? $_SERVER['HTTP_WEIXINPAY2_SIGNATURE'] ?? '';
$serial = $_SERVER['HTTP_WEIXINPAY_SERIAL'] ?? $_SERVER['HTTP_WEIXINPAY2_SERIAL'] ?? '';
$timestamp = $_SERVER['HTTP_WEIXINPAY_TIMESTAMP'] ?? $_SERVER['HTTP_WEIXINPAY2_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_WEIXINPAY_NONCE'] ?? $_SERVER['HTTP_WEIXINPAY2_NONCE'] ?? '';
if (!WeChatClient::verifyNotify($body, $signature, $serial, $timestamp, $nonce)) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['code' => 'FAIL', 'message' => '验签失败']);
exit;
}
$data = json_decode($body, true);
$plain = WeChatClient::decryptResource(
(string)($data['resource']['ciphertext'] ?? ''),
(string)($data['resource']['associated_data'] ?? ''),
(string)($data['resource']['nonce'] ?? ''),
(string)\Soon\Api\Core\Config::get('wechat.api_v3_key', '')
);
if ($plain === null) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['code' => 'FAIL', 'message' => '解密失败']);
exit;
}
$decoded = json_decode($plain, true);
$orderNo = (string)($decoded['out_trade_no'] ?? '');
$txnId = (string)($decoded['transaction_id'] ?? '');
$amountCents = (int)($decoded['amount']['total'] ?? 0);
$tradeState = (string)($decoded['trade_state'] ?? '');
if ($tradeState !== '' && $tradeState !== 'SUCCESS') {
header('Content-Type: application/json');
echo json_encode(['code' => 'SUCCESS']);
exit;
}
if (!PayService::markPaid($orderNo, 'wechat', $txnId, $amountCents)) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['code' => 'FAIL', 'message' => '订单处理失败']);
exit;
}
header('Content-Type: application/json');
echo json_encode(['code' => 'SUCCESS']);
exit;
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Controllers;
use Soon\Api\Core\Json;
use Soon\Api\Middleware\Auth;
use Soon\Api\Services\MembershipService;
final class PlanController
{
public function index(): void
{
Json::ok(['items' => MembershipService::plans()]);
}
public function myPlan(): void
{
$u = Auth::require();
Json::ok([
'membership' => MembershipService::currentPlan($u['id']),
'recent_orders' => MembershipService::recentOrders($u['id']),
]);
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Controllers;
use Soon\Api\Core\Json;
use Soon\Api\Services\MembershipService;
final class SettingsController
{
public function publicSettings(): void
{
Json::ok(MembershipService::settings());
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Controllers;
use Soon\Api\Core\Config;
use Soon\Api\Core\Json;
final class SoonModelController
{
public function index(): void
{
$root = (string)Config::get('storage.models_dir', dirname(SOON_SERVER_ROOT) . '/soonModels');
$manifest = $root . '/manifest.json';
if (!is_file($manifest)) {
Json::ok(['items' => [], 'count' => 0]);
}
$data = json_decode((string)file_get_contents($manifest), true);
if (!is_array($data)) {
Json::fail('manifest_invalid', '模型清单已损坏', 500);
}
Json::ok($data);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Core;
/**
* 静态配置访问器。
*/
final class Config
{
private static array $data = [];
public static function init(array $data): void
{
self::$data = $data;
}
public static function get(string $key, mixed $default = null): mixed
{
$segments = explode('.', $key);
$node = self::$data;
foreach ($segments as $seg) {
if (!is_array($node) || !array_key_exists($seg, $node)) {
return $default;
}
$node = $node[$seg];
}
return $node;
}
public static function all(): array
{
return self::$data;
}
public static function set(string $key, mixed $value): void
{
$segments = explode('.', $key);
$ref = &self::$data;
foreach ($segments as $seg) {
if (!isset($ref[$seg]) || !is_array($ref[$seg])) {
$ref[$seg] = [];
}
$ref = &$ref[$seg];
}
$ref = $value;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Core;
use PDO;
use PDOException;
/**
* 单例 PDO 连接。
*/
final class Db
{
private static ?PDO $pdo = null;
public static function pdo(): PDO
{
if (self::$pdo instanceof PDO) {
return self::$pdo;
}
$cfg = Config::get('db', []);
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$cfg['host'] ?? '127.0.0.1',
(int)($cfg['port'] ?? 3306),
$cfg['name'] ?? 'soondesign',
$cfg['charset'] ?? 'utf8mb4'
);
$opts = $cfg['options'] ?? [];
$opts[PDO::ATTR_ERRMODE] = PDO::ERRMODE_EXCEPTION;
$opts[PDO::ATTR_DEFAULT_FETCH_MODE] = PDO::FETCH_ASSOC;
$opts[PDO::ATTR_EMULATE_PREPARES] = false;
self::$pdo = new PDO($dsn, $cfg['user'] ?? 'root', $cfg['pass'] ?? '', $opts);
return self::$pdo;
}
public static function tx(callable $fn): mixed
{
$pdo = self::pdo();
$pdo->beginTransaction();
try {
$result = $fn($pdo);
$pdo->commit();
return $result;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Core;
/**
* 统一 JSON 响应工具。
* 列表分页 data 形态见仓库 docs/API-PAGINATION.mditems / total / page / size)。
*/
final class Json
{
public static function ok(mixed $data = null, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
public static function fail(string $code, string $message, int $status = 400, array $extra = []): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
$payload = array_merge(['ok' => false, 'error' => $code, 'message' => $message], $extra);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
public static function readBody(): array
{
$raw = file_get_contents('php://input') ?: '';
if ($raw === '') {
return [];
}
$data = json_decode($raw, true);
if (!is_array($data)) {
self::fail('bad_request', '请求体不是合法 JSON', 400);
}
return $data;
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Core;
/**
* HS256 JWT 工具。iss = site.base_url,签发与解码双向校验。
*/
final class Jwt
{
public static function issuer(): string
{
return (string)Config::get('site.base_url', 'soondesign');
}
public static function secret(): string
{
return (string)Config::get('app.jwt_secret', 'change-me-in-prod');
}
public static function ttl(): int
{
return (int)Config::get('app.jwt_ttl', 3600);
}
public static function refreshTtl(): int
{
return (int)Config::get('app.jwt_refresh_ttl', 2592000);
}
public static function encode(array $claims): string
{
$header = ['alg' => 'HS256', 'typ' => 'JWT'];
$payload = array_merge([
'iss' => self::issuer(),
'iat' => time(),
'exp' => time() + self::ttl(),
], $claims);
$h = self::b64u(json_encode($header, JSON_UNESCAPED_UNICODE));
$p = self::b64u(json_encode($payload, JSON_UNESCAPED_UNICODE));
$sig = hash_hmac('sha256', $h . '.' . $p, self::secret(), true);
return $h . '.' . $p . '.' . self::b64u($sig);
}
public static function decode(string $token): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$h, $p, $s] = $parts;
$expected = self::b64u(hash_hmac('sha256', $h . '.' . $p, self::secret(), true));
if (!hash_equals($expected, $s)) {
return null;
}
$payload = json_decode(self::b64uDecode($p), true);
if (!is_array($payload)) {
return null;
}
if (!isset($payload['iss']) || $payload['iss'] !== self::issuer()) {
return null;
}
if (isset($payload['exp']) && $payload['exp'] < time()) {
return null;
}
return $payload;
}
private static function b64u(string $bin): string
{
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}
private static function b64uDecode(string $b64): string
{
$b64 = strtr($b64, '-_', '+/');
$pad = 4 - (strlen($b64) % 4);
if ($pad < 4) {
$b64 .= str_repeat('=', $pad);
}
return base64_decode($b64);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Core;
/**
* 极简文件日志。
*/
final class Logger
{
public static function path(): string
{
$dir = SOON_SERVER_ROOT . '/storage/logs';
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
return $dir;
}
public static function write(string $channel, string $message, array $context = []): void
{
$line = sprintf(
"[%s] %s %s %s\n",
date('Y-m-d H:i:s'),
strtoupper($channel),
$message,
$context ? json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : ''
);
@file_put_contents(self::path() . '/' . $channel . '.log', $line, FILE_APPEND);
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Core;
/**
* 极简路由器。METHOD PATH 模式,支持 `{name}` 占位符。
* 用法:
* Router::get('/users/{id}', [Controller::class, 'show']);
* $match = Router::dispatch('GET', '/users/42');
*/
final class Router
{
/** @var array<string, array<string, callable|array{0:string,1:string}>> */
private static array $routes = [];
public static function get(string $path, callable|array $handler): void
{
self::add('GET', $path, $handler);
}
public static function post(string $path, callable|array $handler): void
{
self::add('POST', $path, $handler);
}
public static function put(string $path, callable|array $handler): void
{
self::add('PUT', $path, $handler);
}
public static function delete(string $path, callable|array $handler): void
{
self::add('DELETE', $path, $handler);
}
public static function add(string $method, string $path, callable|array $handler): void
{
self::$routes[$method][$path] = $handler;
}
/**
* @return array{0: callable|array, 1: array<string,string>}|null
*/
public static function dispatch(string $method, string $path): ?array
{
$method = strtoupper($method);
$candidates = self::$routes[$method] ?? [];
foreach ($candidates as $pattern => $handler) {
$regex = self::compile($pattern);
if (preg_match($regex, $path, $m)) {
$params = [];
foreach ($m as $k => $v) {
if (!is_int($k)) {
$params[$k] = $v;
}
}
return [$handler, $params];
}
}
return null;
}
public static function compile(string $pattern): string
{
$regex = preg_replace_callback('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', static function ($m) {
return '(?P<' . $m[1] . '>[^/]+)';
}, $pattern);
return '#^' . $regex . '$#';
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Middleware;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
use Soon\Api\Core\Jwt;
/**
* 管理员鉴权:JWT 校验 + 数据库 role 检查。
*/
final class AdminAuth
{
public static function require(): int
{
$token = Auth::bearerFromGlobals();
if ($token === '') {
Json::fail('unauthorized', '需要管理员令牌', 401);
}
$payload = Jwt::decode($token);
if ($payload === null) {
Json::fail('unauthorized', '令牌无效或已过期', 401);
}
if (($payload['typ'] ?? '') !== 'access') {
Json::fail('unauthorized', '需要访问令牌', 401);
}
$uid = (int)($payload['sub'] ?? 0);
if ($uid <= 0) {
Json::fail('unauthorized', '令牌主体错误', 401);
}
$stmt = Db::pdo()->prepare('SELECT role, status FROM users WHERE id = :id');
$stmt->execute(['id' => $uid]);
$u = $stmt->fetch();
if (!$u || $u['status'] === 'disabled' || $u['role'] !== 'admin') {
Json::fail('forbidden', '需要管理员权限', 403);
}
return $uid;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Middleware;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
use Soon\Api\Core\Jwt;
/**
* 普通用户 JWT 鉴权。
*/
final class Auth
{
public static function bearerFromGlobals(): string
{
$h = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if ($h === '' && function_exists('apache_request_headers')) {
$headers = apache_request_headers();
$h = $headers['Authorization'] ?? '';
}
if ($h === '' || stripos($h, 'Bearer ') !== 0) {
return '';
}
return trim(substr($h, 7));
}
public static function require(): array
{
$token = self::bearerFromGlobals();
if ($token === '') {
Json::fail('unauthorized', '缺少访问令牌', 401);
}
$payload = Jwt::decode($token);
if ($payload === null) {
Json::fail('unauthorized', '令牌无效或已过期', 401);
}
if (($payload['typ'] ?? '') !== 'access') {
Json::fail('unauthorized', '需要访问令牌', 401);
}
$uid = (int)($payload['sub'] ?? 0);
if ($uid <= 0) {
Json::fail('unauthorized', '令牌主体错误', 401);
}
$stmt = Db::pdo()->prepare('SELECT id, email, role, admin_level, status FROM users WHERE id = :id');
$stmt->execute(['id' => $uid]);
$user = $stmt->fetch();
if (!$user || $user['status'] === 'disabled') {
Json::fail('unauthorized', '账号不存在或已停用', 401);
}
$out = [
'id' => (int)$user['id'],
'email' => $user['email'],
'role' => $user['role'],
];
if ($user['role'] === 'admin') {
$level = (string)($user['admin_level'] ?? 'full');
$out['admin_level'] = $level === 'ops' ? 'ops' : 'full';
}
return $out;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Middleware;
use Soon\Api\Core\Config;
use Soon\Api\Core\Json;
/**
* 基于文件桶的简单限速。
* 规则来自 config.rate_limits
* ['admin/*' => ['capacity' => 60, 'window' => 60], 'POST /api/v1/auth/login' => [...]]
*/
final class RateLimit
{
public static function dir(): string
{
$dir = SOON_SERVER_ROOT . '/storage/cache/rate';
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
return $dir;
}
public static function keyFor(string $method, string $path, ?int $userId): string
{
if ($userId !== null) {
return 'u' . $userId;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
return 'ip' . md5($ip);
}
public static function matchRule(string $method, string $path, array $rules): ?array
{
$needle = $method . ' ' . $path;
foreach ($rules as $pattern => $rule) {
if ($pattern === 'admin/*' && str_starts_with($path, '/api/admin/')) {
return $rule;
}
if (str_contains($pattern, ' ')) {
if ($pattern === $needle) {
return $rule;
}
continue;
}
$regex = '#^' . str_replace('\*', '.*', preg_quote($pattern, '#')) . '$#';
if (preg_match($regex, $path)) {
return $rule;
}
}
return null;
}
public static function check(string $routeKey, string $method, string $path, ?int $userId): void
{
$rules = Config::get('rate_limits', []);
$rule = self::matchRule($method, $path, $rules);
if ($rule === null && $routeKey === 'admin/*') {
$rule = $rules['admin/*'] ?? null;
}
if ($rule === null) {
return;
}
$capacity = (int)($rule['capacity'] ?? 60);
$window = (int)($rule['window'] ?? 60);
$bucket = self::dir() . '/' . md5($routeKey . ':' . self::keyFor($method, $path, $userId)) . '.json';
$now = time();
$data = ['start' => $now, 'count' => 0];
if (is_file($bucket)) {
$raw = json_decode((string)file_get_contents($bucket), true);
if (is_array($raw) && isset($raw['start'], $raw['count'])) {
if ($now - (int)$raw['start'] < $window) {
$data = $raw;
}
}
}
$data['count']++;
@file_put_contents($bucket, json_encode($data));
if ($data['count'] > $capacity) {
Json::fail('rate_limited', '请求过于频繁,请稍后再试', 429);
}
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
final class AdminPermission
{
public static function level(int $adminId): string
{
$stmt = Db::pdo()->prepare(
'SELECT admin_level FROM users WHERE id = :id AND role = \'admin\' AND status = \'active\''
);
$stmt->execute(['id' => $adminId]);
$row = $stmt->fetch();
if (!$row) {
return 'full';
}
$level = (string)($row['admin_level'] ?? 'full');
return $level === 'ops' ? 'ops' : 'full';
}
public static function requireFull(int $adminId): void
{
if (self::level($adminId) !== 'full') {
Json::fail('forbidden', '需要超级管理员权限', 403);
}
}
public static function assertPath(int $adminId, string $path): void
{
if (self::level($adminId) === 'full') {
return;
}
$prefixes = ['/api/admin/settings', '/api/admin/audits', '/api/admin/payment'];
foreach ($prefixes as $prefix) {
if (str_starts_with($path, $prefix)) {
Json::fail('forbidden', '需要超级管理员权限', 403);
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Config;
/**
* 支付宝 PC 网站支付(页面跳转)。
*/
final class AlipayClient
{
/** @return array{gateway: string, params: array<string, string>} */
public static function pagePayForm(string $orderNo, int $amountCents, string $subject): array
{
$cfg = Config::get('alipay', []);
$params = [
'app_id' => (string)($cfg['app_id'] ?? ''),
'method' => 'alipay.trade.page.pay',
'charset' => 'utf-8',
'sign_type' => 'RSA2',
'timestamp' => date('Y-m-d H:i:s'),
'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),
'biz_content' => json_encode([
'out_trade_no' => $orderNo,
'product_code' => 'FAST_INSTANT_TRADE_PAY',
'total_amount' => number_format($amountCents / 100, 2, '.', ''),
'subject' => $subject,
], JSON_UNESCAPED_UNICODE),
];
$params['sign'] = self::sign($params, (string)($cfg['private_key'] ?? ''));
$gateway = ($cfg['sandbox'] ?? false)
? 'https://openapi.alipaydev.com/gateway.do'
: 'https://openapi.alipay.com/gateway.do';
return ['gateway' => $gateway, 'params' => $params];
}
public static function pagePay(string $orderNo, int $amountCents, string $subject): string
{
$form = self::pagePayForm($orderNo, $amountCents, $subject);
return $form['gateway'] . '?' . http_build_query($form['params']);
}
public static function verifyNotify(array $params): bool
{
$cfg = Config::get('alipay', []);
$sign = (string)($params['sign'] ?? '');
if ($sign === '') {
return false;
}
unset($params['sign'], $params['sign_type']);
ksort($params);
$query = '';
foreach ($params as $k => $v) {
if ($v === '' || $v === null) {
continue;
}
$query .= $k . '=' . $v . '&';
}
$query = rtrim($query, '&');
return self::verifyNotifySign($query, $sign, (string)($cfg['public_key'] ?? ''));
}
public static function verifyNotifySign(string $content, string $signBase64, string $alipayPublicKey): bool
{
$pub = openssl_get_publickey(self::normalizePem($alipayPublicKey, 'PUBLIC KEY'));
if ($pub === false) {
return false;
}
$sig = base64_decode($signBase64, true);
if ($sig === false) {
openssl_free_key($pub);
return false;
}
$ok = openssl_verify($content, $sig, $pub, OPENSSL_ALGO_SHA256);
openssl_free_key($pub);
return $ok === 1;
}
private static function sign(array $params, string $privateKey): string
{
ksort($params);
$query = '';
foreach ($params as $k => $v) {
if ($v === '' || $v === null) {
continue;
}
$query .= $k . '=' . $v . '&';
}
$query = rtrim($query, '&');
$res = openssl_get_privatekey(self::normalizePem($privateKey, 'PRIVATE KEY'));
if ($res === false) {
return '';
}
$signature = '';
openssl_sign($query, $signature, $res, OPENSSL_ALGO_SHA256);
openssl_free_key($res);
return base64_encode($signature);
}
public static function refund(string $orderNo, int $amountCents): bool
{
$cfg = Config::get('alipay', []);
$appId = (string)($cfg['app_id'] ?? '');
$privateKey = (string)($cfg['private_key'] ?? '');
if ($appId === '' || $privateKey === '') {
return false;
}
$params = [
'app_id' => $appId,
'method' => 'alipay.trade.refund',
'charset' => 'utf-8',
'sign_type' => 'RSA2',
'timestamp' => date('Y-m-d H:i:s'),
'version' => '1.0',
'biz_content' => json_encode([
'out_trade_no' => $orderNo,
'refund_amount' => number_format($amountCents / 100, 2, '.', ''),
], JSON_UNESCAPED_UNICODE),
];
$params['sign'] = self::sign($params, $privateKey);
$gateway = ($cfg['sandbox'] ?? false)
? 'https://openapi.alipaydev.com/gateway.do'
: 'https://openapi.alipay.com/gateway.do';
$ch = curl_init($gateway);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_TIMEOUT => 20,
]);
$resp = curl_exec($ch);
curl_close($ch);
if (!is_string($resp) || $resp === '') {
return false;
}
$data = json_decode($resp, true);
$key = 'alipay_trade_refund_response';
return is_array($data) && ($data[$key]['code'] ?? '') === '10000';
}
private static function normalizePem(string $key, string $type): string
{
$key = trim($key);
if (str_contains($key, 'BEGIN')) {
return $key;
}
$body = chunk_split(preg_replace('/\s+/', '', $key), 64, "\n");
return "-----BEGIN {$type}-----\n{$body}-----END {$type}-----\n";
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Db;
/**
* 审计日志写入。
*/
final class AuditService
{
public static function log(int $adminId, string $action, string $target, array $context = []): void
{
$stmt = Db::pdo()->prepare(
'INSERT INTO audit_logs (admin_id, action, target, context, ip, created_at) '
. 'VALUES (:aid, :act, :tgt, :ctx, :ip, :ts)'
);
$stmt->execute([
'aid' => $adminId,
'act' => $action,
'tgt' => $target,
'ctx' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
'ts' => date('Y-m-d H:i:s'),
]);
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
use Soon\Api\Core\Jwt;
use Soon\Api\Middleware\Auth;
/**
* 用户认证、注册、令牌刷新。
*/
final class AuthService
{
public static function register(string $email, string $password): array
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
Json::fail('bad_request', '邮箱格式不正确', 400);
}
if (strlen($password) < 8) {
Json::fail('bad_request', '密码至少 8 位', 400);
}
$pdo = Db::pdo();
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :e');
$stmt->execute(['e' => $email]);
if ($stmt->fetch()) {
Json::fail('conflict', '邮箱已注册', 409);
}
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
$stmt = $pdo->prepare(
'INSERT INTO users (email, password_hash, role, status, created_at) '
. 'VALUES (:e, :h, "user", "active", :ts)'
);
$stmt->execute([
'e' => $email,
'h' => $hash,
'ts' => date('Y-m-d H:i:s'),
]);
$uid = (int)$pdo->lastInsertId();
return self::issueTokens($uid);
}
public static function login(string $email, string $password): array
{
$pdo = Db::pdo();
$stmt = $pdo->prepare('SELECT id, password_hash, role, status FROM users WHERE email = :e');
$stmt->execute(['e' => $email]);
$u = $stmt->fetch();
if (!$u || !password_verify($password, $u['password_hash'])) {
Json::fail('unauthorized', '邮箱或密码错误', 401);
}
if ($u['status'] === 'disabled') {
Json::fail('forbidden', '账号已停用', 403);
}
return self::issueTokens((int)$u['id'], $u['role']);
}
public static function refresh(string $refreshToken): array
{
$payload = Jwt::decode($refreshToken);
if ($payload === null || ($payload['typ'] ?? '') !== 'refresh') {
Json::fail('unauthorized', '刷新令牌无效', 401);
}
$uid = (int)($payload['sub'] ?? 0);
if ($uid <= 0) {
Json::fail('unauthorized', '刷新令牌主体错误', 401);
}
$stmt = Db::pdo()->prepare('SELECT role, status FROM users WHERE id = :id');
$stmt->execute(['id' => $uid]);
$u = $stmt->fetch();
if (!$u || $u['status'] === 'disabled') {
Json::fail('unauthorized', '账号不存在', 401);
}
return self::issueTokens($uid, $u['role']);
}
public static function userFromAccessToken(): array
{
$u = Auth::require();
return $u;
}
private static function issueTokens(int $uid, string $role = 'user'): array
{
$access = Jwt::encode(['sub' => $uid, 'role' => $role, 'typ' => 'access']);
$refresh = Jwt::encode([
'sub' => $uid,
'role' => $role,
'typ' => 'refresh',
'exp' => time() + Jwt::refreshTtl(),
]);
return [
'access_token' => $access,
'refresh_token' => $refresh,
'token_type' => 'Bearer',
'expires_in' => Jwt::ttl(),
];
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Config;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
/**
* .soon 文件服务:JSON 数据流模型,乐观锁、配额、软删除。
*/
final class FileService
{
public static function usersRoot(): string
{
$dir = (string)Config::get('storage.users_dir', SOON_SERVER_ROOT . '/storage/users');
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
return $dir;
}
public static function userDir(int $userId): string
{
$root = self::usersRoot();
$dir = $root . '/' . $userId;
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
return $dir;
}
public static function quotaBytes(int $userId): int
{
$plan = MembershipService::currentPlan($userId);
$planQuota = (int)($plan['quota_mb'] ?? 50);
return $planQuota * 1024 * 1024;
}
public static function usedBytes(int $userId): int
{
$stmt = Db::pdo()->prepare('SELECT COALESCE(SUM(size),0) AS s FROM soon_files WHERE user_id = :u AND deleted_at IS NULL');
$stmt->execute(['u' => $userId]);
return (int)$stmt->fetchColumn();
}
public static function fileCount(int $userId): int
{
$stmt = Db::pdo()->prepare('SELECT COUNT(*) FROM soon_files WHERE user_id = :u AND deleted_at IS NULL');
$stmt->execute(['u' => $userId]);
return (int)$stmt->fetchColumn();
}
public static function list(int $userId, int $limit, int $offset): array
{
$stmt = Db::pdo()->prepare(
'SELECT id, name, size, version, updated_at, created_at '
. 'FROM soon_files WHERE user_id = :u AND deleted_at IS NULL '
. 'ORDER BY updated_at DESC LIMIT :lim OFFSET :off'
);
$stmt->bindValue('u', $userId, \PDO::PARAM_INT);
$stmt->bindValue('lim', $limit, \PDO::PARAM_INT);
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
public static function create(int $userId, string $name, string $json): array
{
$maxFiles = MembershipService::maxFilesLimit($userId);
if (self::fileCount($userId) >= $maxFiles) {
Json::fail('file_limit_exceeded', '已达文件数量上限(' . $maxFiles . ' 个),请清理文件或续订', 413);
}
$size = strlen($json);
$quota = self::quotaBytes($userId);
$used = self::usedBytes($userId);
if ($quota > 0 && $used + $size > $quota) {
Json::fail('quota_exceeded', '存储空间已满,请清理文件或续订', 413);
}
$now = date('Y-m-d H:i:s');
$stmt = Db::pdo()->prepare(
'INSERT INTO soon_files (user_id, name, json, size, version, created_at, updated_at) '
. 'VALUES (:u, :n, :j, :s, 1, :created_at, :updated_at)'
);
$stmt->execute(['u' => $userId, 'n' => $name, 'j' => $json, 's' => $size, 'created_at' => $now, 'updated_at' => $now]);
$id = (int)Db::pdo()->lastInsertId();
return ['id' => $id, 'name' => $name, 'size' => $size, 'version' => 1, 'updated_at' => $now];
}
public static function update(int $userId, int $id, string $name, string $json, ?int $expectedVersion): array
{
$newSize = strlen($json);
$quota = self::quotaBytes($userId);
$used = self::usedBytes($userId);
$pdo = Db::pdo();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT * FROM soon_files WHERE id = :id AND user_id = :u AND deleted_at IS NULL FOR UPDATE');
$stmt->execute(['id' => $id, 'u' => $userId]);
$row = $stmt->fetch();
if (!$row) {
throw new \RuntimeException('not_found');
}
if ($expectedVersion !== null && (int)$row['version'] !== $expectedVersion) {
throw new \RuntimeException('version_conflict');
}
$oldSize = (int)$row['size'];
if ($quota > 0 && ($used - $oldSize + $newSize) > $quota) {
throw new \RuntimeException('quota_exceeded');
}
$now = date('Y-m-d H:i:s');
$newVersion = (int)$row['version'] + 1;
$upd = $pdo->prepare(
'UPDATE soon_files SET name = :n, json = :j, size = :s, version = :v, updated_at = :ts '
. 'WHERE id = :id AND version = :cv'
);
$upd->execute([
'n' => $name, 'j' => $json, 's' => $newSize, 'v' => $newVersion,
'ts' => $now, 'id' => $id, 'cv' => (int)$row['version'],
]);
if ($upd->rowCount() === 0) {
throw new \RuntimeException('version_conflict');
}
$pdo->commit();
return ['id' => $id, 'name' => $name, 'size' => $newSize, 'version' => $newVersion, 'updated_at' => $now];
} catch (\RuntimeException $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
if ($e->getMessage() === 'not_found') Json::fail('not_found', '文件不存在', 404);
if ($e->getMessage() === 'version_conflict') Json::fail('conflict', '版本冲突,请刷新后重试', 409);
if ($e->getMessage() === 'quota_exceeded') Json::fail('quota_exceeded', '存储空间已满', 413);
throw $e;
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
}
public static function softDelete(int $userId, int $id): void
{
$stmt = Db::pdo()->prepare('UPDATE soon_files SET deleted_at = :ts WHERE id = :id AND user_id = :u');
$stmt->execute(['ts' => date('Y-m-d H:i:s'), 'id' => $id, 'u' => $userId]);
}
public static function fetch(int $userId, int $id): array
{
$stmt = Db::pdo()->prepare('SELECT * FROM soon_files WHERE id = :id AND user_id = :u AND deleted_at IS NULL');
$stmt->execute(['id' => $id, 'u' => $userId]);
$row = $stmt->fetch();
if (!$row) {
Json::fail('not_found', '文件不存在', 404);
}
return $row;
}
}
@@ -0,0 +1,406 @@
<?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));
}
}
+484
View File
@@ -0,0 +1,484 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Config;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
use Soon\Api\Services\WeChatPay\Client as WeChatClient;
use Throwable;
/**
* 支付订单与通知处理。
*/
final class PayService
{
private const PENDING_REUSE_SECONDS = 7200;
private const PENDING_STALE_SECONDS = 86400;
public static function createOrder(int $userId, int $planId, string $channel, string $clientIp): array
{
$plan = self::loadActivePlan($planId);
self::expireStalePendingOrders($userId);
$existing = self::findReusablePendingOrder($userId, $planId, $channel);
if ($existing !== null) {
return self::attachPaymentPayload($existing, $plan, $clientIp, true);
}
self::cancelPendingForPlan($userId, $planId);
$priceCents = (int)$plan['price_cents'];
$orderNo = 'SOON' . date('YmdHis') . random_int(1000, 9999);
$stmt = Db::pdo()->prepare(
'INSERT INTO pay_orders (order_no, user_id, plan_id, channel, amount_cents, status, client_ip, created_at) '
. 'VALUES (:o, :u, :p, :c, :a, "pending", :ip, :ts)'
);
$stmt->execute([
'o' => $orderNo,
'u' => $userId,
'p' => $planId,
'c' => $channel,
'a' => $priceCents,
'ip' => $clientIp,
'ts' => date('Y-m-d H:i:s'),
]);
$order = [
'id' => (int)Db::pdo()->lastInsertId(),
'order_no' => $orderNo,
'amount_cents' => $priceCents,
'channel' => $channel,
'status' => 'pending',
'plan_id' => $planId,
];
return self::attachPaymentPayload($order, $plan, $clientIp, false);
}
public static function checkoutOrderForUser(int $userId, string $orderNo, string $clientIp, ?string $channel = null): array
{
self::expireStalePendingOrders($userId);
$order = self::findByOrderNoForUser($orderNo, $userId);
if ($order === null) {
Json::fail('not_found', '订单不存在', 404);
}
if ($order['status'] !== 'pending') {
Json::fail('bad_request', '仅待支付订单可继续支付', 400);
}
if (self::isPendingExpired($order)) {
self::cancelPending((int)$order['id']);
Json::fail('gone', '订单已超时,请重新下单', 410);
}
if ($channel !== null && $channel !== $order['channel']) {
if (!in_array($channel, ['alipay', 'wechat'], true)) {
Json::fail('bad_request', '不支持的支付方式', 400);
}
Db::pdo()->prepare('UPDATE pay_orders SET channel = :c WHERE id = :id')
->execute(['c' => $channel, 'id' => $order['id']]);
$order['channel'] = $channel;
}
$plan = self::loadActivePlan((int)$order['plan_id']);
return self::attachPaymentPayload($order, $plan, $clientIp, true);
}
public static function cancelPendingForUser(int $userId, string $orderNo): bool
{
$order = self::findByOrderNoForUser($orderNo, $userId);
if ($order === null) {
Json::fail('not_found', '订单不存在', 404);
}
if ($order['status'] !== 'pending') {
Json::fail('bad_request', '仅待支付订单可取消', 400);
}
if (!self::cancelPending((int)$order['id'])) {
Json::fail('conflict', '订单状态已变更', 409);
}
return true;
}
/** @return array<string, mixed> */
private static function loadActivePlan(int $planId): array
{
$stmt = Db::pdo()->prepare('SELECT * FROM plans WHERE id = :id AND is_active = 1');
$stmt->execute(['id' => $planId]);
$plan = $stmt->fetch();
if (!$plan) {
Json::fail('not_found', '套餐不存在', 404);
}
$priceCents = (int)$plan['price_cents'];
if ($priceCents <= 0) {
Json::fail('bad_request', '免费套餐无需支付', 400);
}
return $plan;
}
private static function expireStalePendingOrders(int $userId): void
{
$cutoff = date('Y-m-d H:i:s', time() - self::PENDING_STALE_SECONDS);
$stmt = Db::pdo()->prepare(
'SELECT id FROM pay_orders WHERE user_id = :u AND status = "pending" AND created_at < :ts'
);
$stmt->execute(['u' => $userId, 'ts' => $cutoff]);
foreach ($stmt->fetchAll() as $row) {
self::cancelPending((int)$row['id']);
}
}
/** @return array<string, mixed>|null */
private static function findReusablePendingOrder(int $userId, int $planId, string $channel): ?array
{
$cutoff = date('Y-m-d H:i:s', time() - self::PENDING_REUSE_SECONDS);
$stmt = Db::pdo()->prepare(
'SELECT * FROM pay_orders WHERE user_id = :u AND plan_id = :p AND channel = :c '
. 'AND status = "pending" AND created_at >= :ts ORDER BY id DESC LIMIT 1'
);
$stmt->execute(['u' => $userId, 'p' => $planId, 'c' => $channel, 'ts' => $cutoff]);
$row = $stmt->fetch();
return $row ?: null;
}
private static function cancelPendingForPlan(int $userId, int $planId): void
{
$stmt = Db::pdo()->prepare(
'SELECT id FROM pay_orders WHERE user_id = :u AND plan_id = :p AND status = "pending"'
);
$stmt->execute(['u' => $userId, 'p' => $planId]);
foreach ($stmt->fetchAll() as $row) {
self::cancelPending((int)$row['id']);
}
}
/** @param array<string, mixed> $order */
private static function isPendingExpired(array $order): bool
{
$created = strtotime((string)($order['created_at'] ?? ''));
if ($created <= 0) {
return true;
}
return (time() - $created) > self::PENDING_REUSE_SECONDS;
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed> $plan
* @return array<string, mixed>
*/
private static function attachPaymentPayload(array $order, array $plan, string $clientIp, bool $reused): array
{
$channel = (string)$order['channel'];
$orderNo = (string)$order['order_no'];
$amountCents = (int)$order['amount_cents'];
$payload = [
'id' => (int)$order['id'],
'order_no' => $orderNo,
'amount_cents' => $amountCents,
'channel' => $channel,
'reused' => $reused,
'plan' => [
'id' => (int)$plan['id'],
'code' => $plan['code'],
'name' => $plan['name'],
],
];
if ($channel === 'alipay') {
$payload['pay_form'] = AlipayClient::pagePayForm(
$orderNo,
$amountCents,
'SoonDesign ' . $plan['name']
);
} else {
$resp = WeChatClient::nativeOrder(
$orderNo,
$amountCents,
'SoonDesign ' . $plan['name'],
$clientIp
);
$payload['qrcode'] = $resp['qrcode'];
}
return $payload;
}
public static function markPaid(string $orderNo, string $channel, string $txnId, ?int $amountCents = null): bool
{
$pdo = Db::pdo();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT * FROM pay_orders WHERE order_no = :o FOR UPDATE');
$stmt->execute(['o' => $orderNo]);
$order = $stmt->fetch();
if (!$order) {
$pdo->rollBack();
return false;
}
if ($order['status'] === 'paid') {
$pdo->commit();
return true;
}
if ($order['channel'] !== $channel) {
$pdo->rollBack();
return false;
}
if ($amountCents !== null && (int)$order['amount_cents'] !== $amountCents) {
$pdo->rollBack();
return false;
}
$planStmt = $pdo->prepare('SELECT * FROM plans WHERE id = :id');
$planStmt->execute(['id' => $order['plan_id']]);
$plan = $planStmt->fetch();
if (!$plan) {
$pdo->rollBack();
return false;
}
$pdo->prepare('UPDATE pay_orders SET status = "paid", txn_id = :t, paid_at = :ts WHERE id = :id')
->execute(['t' => $txnId, 'ts' => date('Y-m-d H:i:s'), 'id' => $order['id']]);
$baseTs = time();
$activeStmt = $pdo->prepare(
'SELECT expires_at FROM subscriptions WHERE user_id = :u AND status = "active" AND expires_at > NOW() '
. 'ORDER BY expires_at DESC LIMIT 1'
);
$activeStmt->execute(['u' => $order['user_id']]);
$activeExpires = $activeStmt->fetchColumn();
if ($activeExpires) {
$baseTs = max($baseTs, strtotime((string)$activeExpires));
}
$expires = date('Y-m-d H:i:s', $baseTs + (int)$plan['duration_days'] * 86400);
$pdo->prepare('UPDATE subscriptions SET status = "expired" WHERE user_id = :u AND status = "active"')
->execute(['u' => $order['user_id']]);
$pdo->prepare(
'INSERT INTO subscriptions (user_id, plan_id, source_order_id, status, started_at, expires_at) '
. 'VALUES (:u, :p, :oid, "active", :sa, :ea)'
)->execute([
'u' => $order['user_id'],
'p' => $order['plan_id'],
'oid' => $order['id'],
'sa' => date('Y-m-d H:i:s'),
'ea' => $expires,
]);
$pdo->commit();
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
public static function findByOrderNo(string $orderNo): ?array
{
$stmt = Db::pdo()->prepare('SELECT * FROM pay_orders WHERE order_no = :o');
$stmt->execute(['o' => $orderNo]);
$row = $stmt->fetch();
return $row ?: null;
}
public static function findByOrderNoForUser(string $orderNo, int $userId): ?array
{
$order = self::findByOrderNo($orderNo);
if ($order === null || (int)$order['user_id'] !== $userId) {
return null;
}
return $order;
}
public static function findById(int $id): ?array
{
$stmt = Db::pdo()->prepare('SELECT * FROM pay_orders WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row ?: null;
}
public static function cancelPending(int $orderId): bool
{
$pdo = Db::pdo();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT * FROM pay_orders WHERE id = :id FOR UPDATE');
$stmt->execute(['id' => $orderId]);
$order = $stmt->fetch();
if (!$order || $order['status'] !== 'pending') {
$pdo->rollBack();
return false;
}
$ts = date('Y-m-d H:i:s');
$pdo->prepare('UPDATE pay_orders SET status = "cancelled", cancelled_at = :ts WHERE id = :id')
->execute(['ts' => $ts, 'id' => $orderId]);
$pdo->commit();
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
public static function requestRefund(int $userId, string $orderNo, string $reason): bool
{
$reason = trim($reason);
if ($reason === '') {
Json::fail('bad_request', '请填写退款原因', 400);
}
$pdo = Db::pdo();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT * FROM pay_orders WHERE order_no = :o AND user_id = :u FOR UPDATE');
$stmt->execute(['o' => $orderNo, 'u' => $userId]);
$order = $stmt->fetch();
if (!$order) {
$pdo->rollBack();
Json::fail('not_found', '订单不存在', 404);
}
if ($order['status'] !== 'paid') {
$pdo->rollBack();
Json::fail('bad_request', '仅已支付订单可申请退款', 400);
}
if ($order['refund_status'] === 'pending') {
$pdo->rollBack();
Json::fail('conflict', '退款申请已在审核中', 409);
}
if ($order['refund_status'] === 'approved' || $order['status'] === 'refunded') {
$pdo->rollBack();
Json::fail('conflict', '订单已退款', 409);
}
$pdo->prepare(
'UPDATE pay_orders SET refund_status = "pending", refund_reason = :r, refund_note = NULL WHERE id = :id'
)->execute(['r' => $reason, 'id' => $order['id']]);
$pdo->commit();
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
/** @return array{ok: bool, gateway: string} */
public static function approveRefund(int $orderId, ?string $adminNote = null): array
{
$pdo = Db::pdo();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT * FROM pay_orders WHERE id = :id FOR UPDATE');
$stmt->execute(['id' => $orderId]);
$order = $stmt->fetch();
if (!$order) {
$pdo->rollBack();
Json::fail('not_found', '订单不存在', 404);
}
if ($order['status'] !== 'paid') {
$pdo->rollBack();
Json::fail('bad_request', '仅已支付订单可退款', 400);
}
if ($order['refund_status'] === 'approved') {
$pdo->rollBack();
Json::fail('conflict', '订单已退款', 409);
}
if ($order['refund_status'] === 'rejected') {
$pdo->rollBack();
Json::fail('bad_request', '退款申请已被拒绝,请让用户重新申请', 400);
}
if (!in_array($order['refund_status'], ['none', 'pending'], true)) {
$pdo->rollBack();
Json::fail('bad_request', '当前状态不可退款', 400);
}
$gateway = self::tryGatewayRefund($order);
$ts = date('Y-m-d H:i:s');
$pdo->prepare(
'UPDATE pay_orders SET status = "refunded", refund_status = "approved", refund_note = :n, refunded_at = :ts '
. 'WHERE id = :id'
)->execute([
'n' => $adminNote !== null && $adminNote !== '' ? $adminNote : null,
'ts' => $ts,
'id' => $orderId,
]);
self::revokeSubscriptionForOrder($pdo, $order);
$pdo->commit();
return ['ok' => true, 'gateway' => $gateway];
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
public static function rejectRefund(int $orderId, string $adminNote): bool
{
$adminNote = trim($adminNote);
if ($adminNote === '') {
Json::fail('bad_request', '请填写拒绝原因', 400);
}
$pdo = Db::pdo();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT * FROM pay_orders WHERE id = :id FOR UPDATE');
$stmt->execute(['id' => $orderId]);
$order = $stmt->fetch();
if (!$order) {
$pdo->rollBack();
Json::fail('not_found', '订单不存在', 404);
}
if ($order['status'] !== 'paid' || $order['refund_status'] !== 'pending') {
$pdo->rollBack();
Json::fail('bad_request', '无待审核的退款申请', 400);
}
$pdo->prepare('UPDATE pay_orders SET refund_status = "rejected", refund_note = :n WHERE id = :id')
->execute(['n' => $adminNote, 'id' => $orderId]);
$pdo->commit();
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
private static function revokeSubscriptionForOrder(\PDO $pdo, array $order): void
{
$oid = (int)$order['id'];
$uid = (int)$order['user_id'];
$pid = (int)$order['plan_id'];
$subStmt = $pdo->prepare(
'SELECT id FROM subscriptions WHERE source_order_id = :oid AND status = "active" LIMIT 1'
);
$subStmt->execute(['oid' => $oid]);
$subId = $subStmt->fetchColumn();
if (!$subId) {
$fallback = $pdo->prepare(
'SELECT id FROM subscriptions WHERE user_id = :u AND plan_id = :p AND status = "active" '
. 'ORDER BY id DESC LIMIT 1'
);
$fallback->execute(['u' => $uid, 'p' => $pid]);
$subId = $fallback->fetchColumn();
}
if ($subId) {
$pdo->prepare('UPDATE subscriptions SET status = "cancelled" WHERE id = :id')
->execute(['id' => $subId]);
}
}
private static function tryGatewayRefund(array $order): string
{
if ($order['channel'] === 'alipay') {
$cfg = Config::get('alipay', []);
if (empty($cfg['private_key']) || empty($cfg['app_id'])) {
return 'skipped_no_keys';
}
return AlipayClient::refund($order['order_no'], (int)$order['amount_cents']) ? 'alipay_ok' : 'alipay_fail';
}
if ($order['channel'] === 'wechat') {
$cfg = Config::get('wechat', []);
if (empty($cfg['mch_private_key']) || empty($cfg['mch_id'])) {
return 'skipped_no_keys';
}
return WeChatClient::refund(
$order['order_no'],
(int)$order['amount_cents'],
(string)($order['txn_id'] ?? '')
) ? 'wechat_ok' : 'wechat_fail';
}
return 'unknown_channel';
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services;
/**
* 从 storage/payment/*.pem 合并支付密钥到 Config。
*/
final class PaymentConfigLoader
{
public static function merge(array &$config): void
{
$dir = SOON_SERVER_ROOT . '/storage/payment';
if (!is_dir($dir)) {
return;
}
$map = [
'alipay_private_key.pem' => ['alipay', 'private_key'],
'alipay_public_key.pem' => ['alipay', 'public_key'],
'wechat_mch_private_key.pem' => ['wechat', 'mch_private_key'],
'wechat_api_v3_key.pem' => ['wechat', 'api_v3_key'],
'wechat_platform_cert.pem' => ['wechat', 'platform_cert'],
];
foreach ($map as $file => [$section, $key]) {
$path = $dir . '/' . $file;
if (!is_file($path)) {
continue;
}
$content = trim((string)file_get_contents($path));
if ($content === '') {
continue;
}
if (!isset($config[$section]) || !is_array($config[$section])) {
$config[$section] = [];
}
$config[$section][$key] = $content;
}
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services\WeChatPay;
/**
* WeChat Pay V3 AEAD_AES_256_GCM 解密。
*/
final class AesGcm
{
public static function decrypt(string $ciphertext, string $associatedData, string $nonce, string $apiV3Key): ?string
{
$key = $apiV3Key;
if (strlen($key) !== 32) {
$key = substr(str_pad($key, 32, "\0"), 0, 32);
}
if (strlen($ciphertext) <= 16) {
return null;
}
$tag = substr($ciphertext, -16);
$body = substr($ciphertext, 0, -16);
$plain = openssl_decrypt(
$body,
'aes-256-gcm',
$key,
OPENSSL_RAW_DATA,
$nonce,
$tag,
$associatedData
);
return $plain === false ? null : $plain;
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services\WeChatPay;
use Soon\Api\Core\Config;
/**
* WeChat 平台证书缓存到 backend/storage/cache/wechat_certs/{serial}.pem。
*/
final class CertCache
{
public static function dir(): string
{
$dir = SOON_SERVER_ROOT . '/storage/cache/wechat_certs';
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
return $dir;
}
public static function get(string $serial): ?string
{
$path = self::dir() . '/' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $serial) . '.pem';
if (is_file($path) && (time() - filemtime($path)) < 43200) {
return (string)file_get_contents($path);
}
$pem = self::fetch($serial);
if ($pem === null) {
return null;
}
@file_put_contents($path, $pem);
return $pem;
}
private static function fetch(string $serial): ?string
{
$cfg = Config::get('wechat', []);
$mchId = (string)($cfg['mch_id'] ?? '');
$url = '/v3/certificates';
$body = '';
$auth = 'mchid="' . $mchId . '",nonce_str="' . bin2hex(random_bytes(8)) . '",timestamp="' . time() . '",serial_no="' . ($cfg['mch_serial_no'] ?? '') . '",signature="' . self::sign($url, $cfg) . '"';
$ch = curl_init('https://api.mch.weixin.qq.com' . $url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: WECHATPAY2-SHA256-RSA2048 ' . $auth,
'Accept: application/json',
],
CURLOPT_TIMEOUT => 15,
]);
$resp = (string)curl_exec($ch);
curl_close($ch);
$data = json_decode($resp, true);
if (!is_array($data) || !isset($data['data'])) {
return null;
}
foreach ($data['data'] as $cert) {
if (($cert['serial_no'] ?? '') === $serial) {
$enc = $cert['encrypt_certificate'] ?? null;
if (!is_array($enc)) return null;
$plain = AesGcm::decrypt(
base64_decode((string)$enc['ciphertext'], true),
(string)$enc['associated_data'],
(string)$enc['nonce'],
(string)($cfg['api_v3_key'] ?? '')
);
return $plain === null ? null : $plain;
}
}
return null;
}
private static function sign(string $url, array $cfg): string
{
$message = "GET\n" . $url . "\n" . time() . "\n" . bin2hex(random_bytes(8)) . "\n\n";
openssl_sign($message, $sig, (string)($cfg['mch_private_key'] ?? ''), OPENSSL_ALGO_SHA256);
return base64_encode($sig);
}
}
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services\WeChatPay;
use Soon\Api\Core\Config;
/**
* WeChat Pay V3 Native 下单 + 平台证书管理。
*/
final class Client
{
public static function baseUrl(): string
{
$cfg = Config::get('wechat', []);
return ($cfg['sandbox'] ?? false) ? 'https://api.mch.weixin.qq.com' : 'https://api.mch.weixin.qq.com';
}
public static function nativeOrder(string $orderNo, int $amountCents, string $description, string $clientIp): array
{
$cfg = Config::get('wechat', []);
$mchId = (string)($cfg['mch_id'] ?? '');
$url = '/v3/pay/transactions/native';
$body = json_encode([
'mchid' => $mchId,
'out_trade_no' => $orderNo,
'appid' => (string)($cfg['app_id'] ?? ''),
'description' => $description,
'notify_url' => (string)Config::get('site.base_url', '') . '/api/v1/pay/wechat/notify',
'amount' => ['total' => $amountCents, 'currency' => 'CNY'],
], JSON_UNESCAPED_UNICODE);
$token = self::auth('POST', $url, $body, (string)($cfg['mch_private_key'] ?? ''));
$resp = self::http('POST', $url, $body, $token);
$data = json_decode($resp['body'], true);
return [
'http_status' => $resp['status'],
'qrcode' => $data['code_url'] ?? null,
'raw' => $data,
];
}
public static function verifyNotify(string $body, string $signature, string $serial, string $timestamp, string $nonce): bool
{
$cert = CertCache::get($serial);
if ($cert === null) {
$cfg = Config::get('wechat', []);
$cert = (string)($cfg['platform_cert'] ?? '');
if ($cert === '') {
return false;
}
}
return Signature::verifyNotify('POST', '/api/v1/pay/wechat/notify', $timestamp, $nonce, $body, $signature, $cert);
}
public static function decryptResource(string $ciphertext, string $associatedData, string $nonce, string $apiV3Key): ?string
{
$raw = base64_decode($ciphertext, true);
if ($raw === false) {
return null;
}
return AesGcm::decrypt($raw, $associatedData, $nonce, $apiV3Key);
}
private static function auth(string $method, string $url, string $body, string $privateKey): string
{
$cfg = Config::get('wechat', []);
$mchId = (string)($cfg['mch_id'] ?? '');
$serial = (string)($cfg['mch_serial_no'] ?? '');
$nonceStr = bin2hex(random_bytes(8));
$timestamp = (string)time();
$message = $method . "\n" . $url . "\n" . $timestamp . "\n" . $nonceStr . "\n" . $body . "\n";
openssl_sign($message, $sig, $privateKey, OPENSSL_ALGO_SHA256);
$signature = base64_encode($sig);
$token = sprintf(
'mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"',
$mchId, $nonceStr, $timestamp, $serial, $signature
);
return 'WECHATPAY2-SHA256-RSA2048 ' . $token;
}
public static function refund(string $orderNo, int $amountCents, string $txnId): bool
{
if ($txnId === '') {
return false;
}
$cfg = Config::get('wechat', []);
$mchId = (string)($cfg['mch_id'] ?? '');
$privateKey = (string)($cfg['mch_private_key'] ?? '');
if ($mchId === '' || $privateKey === '') {
return false;
}
$url = '/v3/refund/domestic/refunds';
$outRefundNo = 'RF' . $orderNo . random_int(100, 999);
$body = json_encode([
'transaction_id' => $txnId,
'out_refund_no' => $outRefundNo,
'reason' => '管理员审核退款',
'amount' => [
'refund' => $amountCents,
'total' => $amountCents,
'currency' => 'CNY',
],
], JSON_UNESCAPED_UNICODE);
$token = self::auth('POST', $url, $body, $privateKey);
$resp = self::http('POST', $url, $body, $token);
if ($resp['status'] < 200 || $resp['status'] >= 300) {
return false;
}
$data = json_decode($resp['body'], true);
return is_array($data) && !empty($data['refund_id']);
}
private static function http(string $method, string $url, string $body, string $auth): array
{
$ch = curl_init(self::baseUrl() . $url);
$headers = [
'Authorization: ' . $auth,
'Content-Type: application/json',
'Accept: application/json',
'User-Agent: SoonDesign/1.0',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => 20,
]);
$resp = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['status' => $status, 'body' => (string)$resp];
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services\WeChatPay;
/**
* WeChat Pay V3 签名(商户私钥 SHA256withRSA)。
*/
final class Signature
{
public static function sign(string $method, string $url, string $body, string $mchPrivateKey): string
{
$raw = strtoupper($method) . "\n" . $url . "\n" . $body . "\n";
$res = openssl_get_privatekey($mchPrivateKey);
if ($res === false) {
return '';
}
$signature = '';
openssl_sign($raw, $signature, $res, OPENSSL_ALGO_SHA256);
openssl_free_key($res);
return base64_encode($signature);
}
public static function verify(string $method, string $url, string $body, string $signature, string $wxPublicKey): bool
{
$raw = strtoupper($method) . "\n" . $url . "\n" . $body . "\n";
return self::verifyRaw($raw, $signature, $wxPublicKey);
}
/** 回调验签:method + url + timestamp + nonce + body(各一行) */
public static function verifyNotify(string $method, string $url, string $timestamp, string $nonce, string $body, string $signature, string $wxPublicKey): bool
{
$raw = strtoupper($method) . "\n" . $url . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n";
return self::verifyRaw($raw, $signature, $wxPublicKey);
}
private static function verifyRaw(string $raw, string $signature, string $wxPublicKey): bool
{
$res = openssl_get_publickey($wxPublicKey);
if ($res === false) {
return false;
}
$sig = base64_decode($signature, true);
$ok = openssl_verify($raw, $sig ?: '', $res, OPENSSL_ALGO_SHA256);
openssl_free_key($res);
return $ok === 1;
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
/**
* Bootstrap: 配置加载、自动加载、生产环境禁止回退 example 配置。
*/
if (defined('SOON_BOOTSTRAPPED')) {
return;
}
define('SOON_BOOTSTRAPPED', true);
define('SOON_API_ROOT', __DIR__);
define('SOON_SERVER_ROOT', dirname(__DIR__));
$configDir = SOON_SERVER_ROOT . '/config';
$localConfig = $configDir . '/local.php';
$exampleConfig = $configDir . '/local.php.example';
$hostConfig = null;
if (isset($_SERVER['HTTP_HOST'])) {
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $_SERVER['HTTP_HOST']));
$candidate = $configDir . '/config.' . $slug . '.php';
if (is_file($candidate)) {
$hostConfig = $candidate;
}
}
if (is_file($localConfig)) {
$config = require $localConfig;
} elseif ($hostConfig !== null) {
$config = require $hostConfig;
} else {
$allowExample = PHP_SAPI === 'cli' || getenv('SOON_ALLOW_EXAMPLE_CONFIG') === '1';
if ($allowExample && is_file($exampleConfig)) {
$config = require $exampleConfig;
} else {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'error' => 'config_missing',
'message' => '未找到 backend-web/config/local.php,请先写入生产配置。',
], JSON_UNESCAPED_UNICODE);
exit;
}
}
if (!is_array($config)) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'config_invalid'], JSON_UNESCAPED_UNICODE);
exit;
}
if (!isset($config['app']) || !is_array($config['app'])) {
$config['app'] = [];
}
if (!isset($config['app']['env'])) {
$config['app']['env'] = 'production';
}
if (!isset($config['app']['debug'])) {
$config['app']['debug'] = false;
}
if ($config['app']['debug']) {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
} else {
ini_set('display_errors', '0');
error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED);
}
date_default_timezone_set($config['app']['timezone'] ?? 'Asia/Shanghai');
spl_autoload_register(static function (string $class): void {
$prefixes = [
'Soon\\Api\\' => SOON_API_ROOT . '/',
'Soon\\Admin\\' => SOON_API_ROOT . '/',
];
foreach ($prefixes as $prefix => $baseDir) {
if (strpos($class, $prefix) !== 0) {
continue;
}
$relative = substr($class, strlen($prefix));
$path = $baseDir . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) {
require $path;
return;
}
}
});
require SOON_API_ROOT . '/Core/Config.php';
\Soon\Api\Core\Config::init($config);
require SOON_API_ROOT . '/Services/PaymentConfigLoader.php';
\Soon\Api\Services\PaymentConfigLoader::merge($config);
\Soon\Api\Core\Config::init($config);