重构 monorepo 并完善网页端订阅与首页体验
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user