前端配置迁至 config/local.js,完善支付、模板库与部署脚本
- 页面直接引用 local.js 设置 SOON_DEPLOY_CONFIG,移除 deploy-config - Docker sync-config 生成 local.js;更新 README 与 agent-core 说明 - 模板库自动扫描 .soon;新增后端部署/种子/排查脚本 - 完善支付配置、订阅弹窗与后台支付管理页 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,7 +26,7 @@ return [
|
||||
],
|
||||
'storage' => [
|
||||
'users_dir' => '/www/wwwroot/designadmin.cardsoon.com/backend/storage/users',
|
||||
'models_dir' => '/www/wwwroot/design.cardsoon.com/soonModels',
|
||||
'models_dir' => '/www/wwwroot/designadmin.cardsoon.com/soonModels',
|
||||
],
|
||||
'limits' => [
|
||||
'free_quota_mb' => 20,
|
||||
@@ -54,4 +54,7 @@ return [
|
||||
'api_v3_key' => '',
|
||||
'sandbox' => false,
|
||||
],
|
||||
'payment' => [
|
||||
'display_channels' => 'alipay',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -65,6 +65,7 @@ Router::post('/api/admin/settings/batch', [SettingsController::class, 'setBatch'
|
||||
Router::get('/api/admin/audits', [AuditsController::class, 'list']);
|
||||
|
||||
Router::post('/api/admin/payment/upload', [PaymentController::class, 'upload']);
|
||||
Router::post('/api/admin/payment/config', [PaymentController::class, 'saveConfig']);
|
||||
Router::get('/api/admin/payment/status', [PaymentController::class, 'status']);
|
||||
|
||||
if (str_starts_with($path, '/api/admin/')) {
|
||||
|
||||
@@ -61,6 +61,7 @@ Router::post('/api/v1/pay/orders/{order_no}/refund-request', [PayController::cla
|
||||
Router::post('/api/v1/pay/alipay/notify', [PayController::class, 'alipayNotify']);
|
||||
Router::post('/api/v1/pay/wechat/notify', [PayController::class, 'wechatNotify']);
|
||||
|
||||
Router::get('/api/v1/soon-models/files/{name}', [SoonModelController::class, 'download']);
|
||||
Router::get('/api/v1/soon-models', [SoonModelController::class, 'index']);
|
||||
Router::get('/api/v1/settings', [SettingsController::class, 'publicSettings']);
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 部署脚本
|
||||
|
||||
## 权限(推荐:SSH 粘贴,勿 FTP 上传 .sh)
|
||||
|
||||
FTP 会把脚本改成 CRLF,报 `set: pipefail`。**在服务器 SSH 整段粘贴:**
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/designadmin.cardsoon.com
|
||||
U=www; G=www; ROOT="$PWD"
|
||||
mkdir -p "$ROOT/storage"/{users,logs,payment,cache/rate,cache/wechat_certs}
|
||||
chown -R "$U:$G" "$ROOT"
|
||||
find "$ROOT/src" "$ROOT/public" -type d -exec chmod 755 {} +
|
||||
find "$ROOT/src" "$ROOT/public" -type f -exec chmod 644 {} +
|
||||
chmod 755 "$ROOT/config"
|
||||
test -f "$ROOT/config/local.php" && chmod 640 "$ROOT/config/local.php"
|
||||
chmod 775 "$ROOT/storage/users" "$ROOT/storage/logs" "$ROOT/storage/cache/rate" "$ROOT/storage/cache/wechat_certs"
|
||||
chmod 700 "$ROOT/storage/payment"
|
||||
rm -f "$ROOT/storage/cache/rate/"*.json
|
||||
echo OK
|
||||
```
|
||||
|
||||
## 上线步骤
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/designadmin.cardsoon.com
|
||||
```
|
||||
|
||||
```bash
|
||||
cp config/local.php.example config/local.php
|
||||
vi config/local.php
|
||||
```
|
||||
|
||||
改 `db`、`jwt_secret`、`site`、`storage` 路径。
|
||||
|
||||
```bash
|
||||
mysql -u root -p soondesign < schema.sql
|
||||
```
|
||||
|
||||
再执行上面「权限」整段粘贴。
|
||||
|
||||
## 初始化管理员
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/designadmin.cardsoon.com
|
||||
php scripts/seed-admin.php admin@cardsoon.com cardsoon
|
||||
```
|
||||
|
||||
成功输出 `OK created admin` 或 `OK updated admin`。登录:`https://design.cardsoon.com/pages/admin/login.html`
|
||||
|
||||
模型目录:`.soon` 放后端站点(推荐,避免防跨站读不到):
|
||||
|
||||
```bash
|
||||
mkdir -p /www/wwwroot/designadmin.cardsoon.com/soonModels
|
||||
cp /www/wwwroot/design.cardsoon.com/soonModels/*.soon /www/wwwroot/designadmin.cardsoon.com/soonModels/
|
||||
chmod 755 /www/wwwroot/designadmin.cardsoon.com/soonModels
|
||||
chmod 644 /www/wwwroot/designadmin.cardsoon.com/soonModels/*.soon
|
||||
```
|
||||
|
||||
`local.php`:
|
||||
|
||||
```php
|
||||
'models_dir' => '/www/wwwroot/designadmin.cardsoon.com/soonModels',
|
||||
```
|
||||
|
||||
排查:
|
||||
|
||||
```bash
|
||||
php scripts/check-soon-models.php
|
||||
```
|
||||
|
||||
## 宝塔
|
||||
|
||||
- 网站运行目录:`public`
|
||||
- Nginx:`/api/v1/` → `index.php`,`/api/admin/` → `admin.php`
|
||||
|
||||
## 配置
|
||||
|
||||
后端只维护 `config/local.php`(从 `local.php.example` 复制)。
|
||||
|
||||
前端(`design.cardsoon.com`)只维护 `config/local.js`(从 `config/local.js.example` 复制);全量更新 frontend 时不要覆盖。
|
||||
|
||||
## 本地脚本
|
||||
|
||||
`setup-backend-perms.sh` 供 git 拉取使用;Windows/FTP 上传易坏,请用上面 SSH 粘贴块。
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "CLI only\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/src/bootstrap.php';
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Services\SoonModelService;
|
||||
|
||||
$configured = rtrim((string)Config::get('storage.models_dir', ''), '/\\');
|
||||
$local = SOON_SERVER_ROOT . '/soonModels';
|
||||
|
||||
fwrite(STDOUT, "configured models_dir: {$configured}\n");
|
||||
fwrite(STDOUT, " is_dir=" . (@is_dir($configured) ? 'yes' : 'no') . ' readable=' . (@is_readable($configured) ? 'yes' : 'no') . "\n");
|
||||
|
||||
fwrite(STDOUT, "backend soonModels: {$local}\n");
|
||||
fwrite(STDOUT, " is_dir=" . (@is_dir($local) ? 'yes' : 'no') . ' readable=' . (@is_readable($local) ? 'yes' : 'no') . "\n");
|
||||
|
||||
$roots = SoonModelService::roots();
|
||||
fwrite(STDOUT, 'scan roots: ' . (count($roots) ? implode(', ', $roots) : '(none)') . "\n");
|
||||
|
||||
foreach ($roots as $root) {
|
||||
$entries = @scandir($root) ?: [];
|
||||
$soon = array_values(array_filter($entries, static fn(string $e): bool => str_ends_with(strtolower($e), '.soon')));
|
||||
fwrite(STDOUT, " {$root} => " . count($soon) . " .soon\n");
|
||||
foreach ($soon as $f) {
|
||||
fwrite(STDOUT, " - {$f}\n");
|
||||
}
|
||||
}
|
||||
|
||||
$items = SoonModelService::list();
|
||||
fwrite(STDOUT, 'API items: ' . count($items) . "\n");
|
||||
foreach ($items as $item) {
|
||||
fwrite(STDOUT, ' - ' . ($item['name'] ?? '') . ' (' . ($item['file'] ?? '') . ")\n");
|
||||
}
|
||||
|
||||
if ($configured !== '' && !@is_readable($configured)) {
|
||||
fwrite(STDOUT, "\nHint: PHP 读不到 configured 目录。把 .soon 复制到后端 {$local},或在宝塔后端站点防跨站放行:{$configured}\n");
|
||||
}
|
||||
|
||||
exit(count($items) > 0 ? 0 : 1);
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 创建或重置管理员(CLI,读取 config/local.php)。
|
||||
* 用法: cd backend-web根目录 && php scripts/seed-admin.php [email] [password]
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "CLI only\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/src/bootstrap.php';
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
|
||||
$email = trim((string)($argv[1] ?? getenv('SOON_ADMIN_EMAIL') ?: 'admin@cardsoon.com'));
|
||||
$plain = (string)($argv[2] ?? getenv('SOON_ADMIN_PASS') ?: 'cardsoon');
|
||||
|
||||
if ($email === '' || $plain === '') {
|
||||
fwrite(STDERR, "usage: php scripts/seed-admin.php [email] [password]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$pdo = Db::pdo();
|
||||
$hash = password_hash($plain, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$stmt->execute(['email' => $email]);
|
||||
$id = $stmt->fetchColumn();
|
||||
|
||||
if ($id) {
|
||||
$pdo->prepare(
|
||||
'UPDATE users SET password_hash = :h, role = :role, admin_level = :level, status = :status WHERE id = :id'
|
||||
)->execute([
|
||||
'h' => $hash,
|
||||
'role' => 'admin',
|
||||
'level' => 'full',
|
||||
'status' => 'active',
|
||||
'id' => (int)$id,
|
||||
]);
|
||||
fwrite(STDOUT, "OK updated admin #{$id} {$email}\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO users (email, password_hash, role, admin_level, status, created_at) '
|
||||
. 'VALUES (:email, :h, :role, :level, :status, :created_at)'
|
||||
)->execute([
|
||||
'email' => $email,
|
||||
'h' => $hash,
|
||||
'role' => 'admin',
|
||||
'level' => 'full',
|
||||
'status' => 'active',
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
fwrite(STDOUT, "OK created admin {$email}\n");
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# cd 到 backend-web 根目录后: bash scripts/setup-backend-perms.sh [soonModels路径]
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "${1:-.}" && pwd)"
|
||||
MODELS="${2:-}"
|
||||
U="${WEB_USER:-www}"
|
||||
G="${WEB_GROUP:-www}"
|
||||
|
||||
[[ -d "$ROOT/public" && -d "$ROOT/src" ]] || { echo "错误: 请先 cd 到 backend-web"; exit 1; }
|
||||
id "$U" &>/dev/null || { echo "错误: 用户 $U 不存在"; exit 1; }
|
||||
|
||||
mkdir -p "$ROOT/storage"/{users,logs,payment,cache/rate,cache/wechat_certs}
|
||||
chown -R "$U:$G" "$ROOT"
|
||||
find "$ROOT/src" "$ROOT/public" -type d -exec chmod 755 {} +
|
||||
find "$ROOT/src" "$ROOT/public" -type f -exec chmod 644 {} +
|
||||
chmod 755 "$ROOT/config"
|
||||
[[ -f "$ROOT/config/local.php" ]] && chmod 640 "$ROOT/config/local.php"
|
||||
chmod 775 "$ROOT/storage/users" "$ROOT/storage/logs" "$ROOT/storage/cache/rate" "$ROOT/storage/cache/wechat_certs"
|
||||
chmod 700 "$ROOT/storage/payment"
|
||||
rm -f "$ROOT/storage/cache/rate/"*.json 2>/dev/null || true
|
||||
|
||||
if [[ -n "$MODELS" ]]; then
|
||||
mkdir -p "$MODELS"
|
||||
chown -R "$U:$G" "$MODELS"
|
||||
chmod 755 "$MODELS"
|
||||
fi
|
||||
|
||||
for d in "$ROOT/storage/users" "$ROOT/storage/logs" "$ROOT/storage/payment"; do
|
||||
sudo -u "$U" test -w "$d" || { echo "FAIL: $d"; exit 1; }
|
||||
done
|
||||
|
||||
echo OK
|
||||
@@ -5,6 +5,7 @@ namespace Soon\Api\Admin\Controllers;
|
||||
|
||||
use Soon\Api\Admin\Services\PaymentKeyStore;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\PaymentConfig;
|
||||
|
||||
final class PaymentController
|
||||
{
|
||||
@@ -24,4 +25,14 @@ final class PaymentController
|
||||
{
|
||||
Json::ok(PaymentKeyStore::status($adminId));
|
||||
}
|
||||
|
||||
public function saveConfig(int $adminId): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$items = $body['items'] ?? null;
|
||||
if (!is_array($items)) {
|
||||
Json::fail('bad_request', 'items 须为数组', 400);
|
||||
}
|
||||
Json::ok(PaymentConfig::saveParams($adminId, $items));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace Soon\Api\Admin\Controllers;
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\AuditService;
|
||||
use Soon\Api\Services\PaymentConfig;
|
||||
use Soon\Api\Services\SettingsConfigLoader;
|
||||
|
||||
final class SettingsController
|
||||
{
|
||||
@@ -30,6 +32,9 @@ final class SettingsController
|
||||
);
|
||||
$stmt->execute(['k' => $key, 'v' => $value, 'ts' => date('Y-m-d H:i:s')]);
|
||||
AuditService::log($adminId, 'settings.set', 'setting:' . $key, ['value' => $value]);
|
||||
if (in_array($key, SettingsConfigLoader::PAYMENT_KEYS, true)) {
|
||||
PaymentConfig::reloadRuntime();
|
||||
}
|
||||
Json::ok(['key' => $key]);
|
||||
}
|
||||
|
||||
@@ -63,6 +68,9 @@ final class SettingsController
|
||||
Json::fail('bad_request', '无有效配置项', 400);
|
||||
}
|
||||
AuditService::log($adminId, 'settings.set_batch', 'settings', ['keys' => $saved]);
|
||||
if (array_intersect($saved, SettingsConfigLoader::PAYMENT_KEYS) !== []) {
|
||||
PaymentConfig::reloadRuntime();
|
||||
}
|
||||
Json::ok(['keys' => $saved]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ 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;
|
||||
use Soon\Api\Services\PaymentConfig;
|
||||
|
||||
/**
|
||||
* 支付密钥保存到 storage/payment/,不入库。
|
||||
@@ -42,9 +41,7 @@ final class PaymentKeyStore
|
||||
AuditService::log($adminId, 'payment.key.upload', $channel . ':' . $kind, [
|
||||
'bytes' => strlen($content),
|
||||
]);
|
||||
$config = Config::all();
|
||||
PaymentConfigLoader::merge($config);
|
||||
Config::init($config);
|
||||
PaymentConfig::reloadRuntime();
|
||||
return ['channel' => $channel, 'kind' => $kind, 'bytes' => strlen($content)];
|
||||
}
|
||||
|
||||
@@ -60,6 +57,9 @@ final class PaymentKeyStore
|
||||
];
|
||||
}
|
||||
AuditService::log($adminId, 'payment.key.status', 'payment');
|
||||
return ['items' => $items];
|
||||
return array_merge(
|
||||
['items' => $items],
|
||||
PaymentConfig::adminSnapshot()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use Soon\Api\Middleware\Auth;
|
||||
use Soon\Api\Services\MembershipService;
|
||||
use Soon\Api\Services\AlipayClient;
|
||||
use Soon\Api\Services\PayService;
|
||||
use Soon\Api\Services\PaymentConfig;
|
||||
use Soon\Api\Services\WeChatPay\Client as WeChatClient;
|
||||
|
||||
final class PayController
|
||||
@@ -25,10 +26,12 @@ final class PayController
|
||||
$u = Auth::require();
|
||||
$body = Json::readBody();
|
||||
$planId = (int)($body['plan_id'] ?? 0);
|
||||
$channel = (string)($body['channel'] ?? 'alipay');
|
||||
$channels = PaymentConfig::displayChannels();
|
||||
$channel = (string)($body['channel'] ?? $channels[0]);
|
||||
if (!in_array($channel, ['alipay', 'wechat'], true)) {
|
||||
Json::fail('bad_request', '不支持的支付方式', 400);
|
||||
}
|
||||
PaymentConfig::assertChannelAllowed($channel);
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
Json::ok(PayService::createOrder($u['id'], $planId, $channel, $ip));
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ declare(strict_types=1);
|
||||
namespace Soon\Api\Controllers;
|
||||
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\MembershipService;
|
||||
use Soon\Api\Services\PaymentConfig;
|
||||
|
||||
final class SettingsController
|
||||
{
|
||||
public function publicSettings(): void
|
||||
{
|
||||
Json::ok(MembershipService::settings());
|
||||
Json::ok([
|
||||
'payment_display_channels' => PaymentConfig::displayChannels(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,22 +3,28 @@ declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Controllers;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\SoonModelService;
|
||||
|
||||
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]);
|
||||
$items = SoonModelService::list();
|
||||
Json::ok(['items' => $items, 'count' => count($items)]);
|
||||
}
|
||||
|
||||
public function download(string $name): void
|
||||
{
|
||||
$path = SoonModelService::resolvePath($name);
|
||||
if ($path === null) {
|
||||
Json::fail('not_found', '模板不存在', 404);
|
||||
}
|
||||
$data = json_decode((string)file_get_contents($manifest), true);
|
||||
if (!is_array($data)) {
|
||||
Json::fail('manifest_invalid', '模型清单已损坏', 500);
|
||||
}
|
||||
Json::ok($data);
|
||||
$base = basename($path);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Content-Disposition: inline; filename="' . str_replace('"', '', $base) . '"');
|
||||
header('Cache-Control: public, max-age=300');
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ final class PayService
|
||||
if (!in_array($channel, ['alipay', 'wechat'], true)) {
|
||||
Json::fail('bad_request', '不支持的支付方式', 400);
|
||||
}
|
||||
PaymentConfig::assertChannelAllowed($channel);
|
||||
Db::pdo()->prepare('UPDATE pay_orders SET channel = :c WHERE id = :id')
|
||||
->execute(['c' => $channel, 'id' => $order['id']]);
|
||||
$order['channel'] = $channel;
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
|
||||
/**
|
||||
* 支付参数、密钥就绪检查与 Admin 读写。
|
||||
*/
|
||||
final class PaymentConfig
|
||||
{
|
||||
/** @var array<string, list<string>> */
|
||||
private const REQUIRED = [
|
||||
'alipay' => ['site.base_url', 'alipay.app_id', 'alipay.private_key', 'alipay.public_key'],
|
||||
'wechat' => [
|
||||
'site.base_url',
|
||||
'wechat.app_id',
|
||||
'wechat.mch_id',
|
||||
'wechat.mch_serial_no',
|
||||
'wechat.mch_private_key',
|
||||
'wechat.api_v3_key',
|
||||
'wechat.platform_cert',
|
||||
],
|
||||
];
|
||||
|
||||
/** @var array<string, array{label: string, hint: string}> */
|
||||
public const PARAM_FIELDS = [
|
||||
'site.base_url' => [
|
||||
'label' => 'API 外网地址',
|
||||
'hint' => '须 HTTPS 外网可达,用于支付 notify 回调',
|
||||
],
|
||||
'site.front_base_url' => [
|
||||
'label' => '前端站点地址',
|
||||
'hint' => '支付宝支付完成后的 return_url 跳转',
|
||||
],
|
||||
'alipay.app_id' => [
|
||||
'label' => '支付宝 APPID',
|
||||
'hint' => '开放平台应用 ID',
|
||||
],
|
||||
'alipay.sandbox' => [
|
||||
'label' => '支付宝环境',
|
||||
'hint' => '沙箱仅联调使用',
|
||||
],
|
||||
'wechat.app_id' => [
|
||||
'label' => '微信 AppID',
|
||||
'hint' => '关联支付的公众号/小程序/移动应用 AppID',
|
||||
],
|
||||
'wechat.mch_id' => [
|
||||
'label' => '微信商户号',
|
||||
'hint' => '微信支付商户号 mch_id',
|
||||
],
|
||||
'wechat.mch_serial_no' => [
|
||||
'label' => '商户证书序列号',
|
||||
'hint' => '商户 API 证书序列号,用于 V3 签名',
|
||||
],
|
||||
'wechat.sandbox' => [
|
||||
'label' => '微信环境',
|
||||
'hint' => '当前实现共用正式网关,仅作标记',
|
||||
],
|
||||
'payment.display_channels' => [
|
||||
'label' => '前端展示渠道',
|
||||
'hint' => '用户端可选的支付方式,默认仅支付宝',
|
||||
],
|
||||
];
|
||||
|
||||
/** @return list<string> */
|
||||
public static function displayChannels(): array
|
||||
{
|
||||
$raw = Config::get('payment.display_channels');
|
||||
if ($raw === null || $raw === '') {
|
||||
return ['alipay'];
|
||||
}
|
||||
$parts = is_array($raw)
|
||||
? $raw
|
||||
: (preg_split('/[\s,]+/', (string)$raw, -1, PREG_SPLIT_NO_EMPTY) ?: []);
|
||||
$allowed = ['alipay', 'wechat'];
|
||||
$out = [];
|
||||
foreach ($parts as $p) {
|
||||
$p = strtolower(trim((string)$p));
|
||||
if ($p !== '' && in_array($p, $allowed, true) && !in_array($p, $out, true)) {
|
||||
$out[] = $p;
|
||||
}
|
||||
}
|
||||
return $out !== [] ? $out : ['alipay'];
|
||||
}
|
||||
|
||||
public static function assertChannelAllowed(string $channel): void
|
||||
{
|
||||
if (!in_array($channel, self::displayChannels(), true)) {
|
||||
Json::fail('bad_request', '该支付方式暂未开放', 400);
|
||||
}
|
||||
}
|
||||
|
||||
public static function reloadRuntime(): void
|
||||
{
|
||||
$config = Config::all();
|
||||
PaymentConfigLoader::merge($config);
|
||||
SettingsConfigLoader::merge($config);
|
||||
Config::init($config);
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public static function paramValues(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (SettingsConfigLoader::PAYMENT_KEYS as $key) {
|
||||
$out[$key] = self::stringValue($key, Config::get($key));
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param list<array{key: string, value: string}> $items */
|
||||
public static function saveParams(int $adminId, array $items): array
|
||||
{
|
||||
$allowed = array_flip(SettingsConfigLoader::PAYMENT_KEYS);
|
||||
$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 === '' || !isset($allowed[$key])) {
|
||||
continue;
|
||||
}
|
||||
$value = trim((string)($item['value'] ?? ''));
|
||||
if ($key === 'site.base_url' && $value !== '' && !str_starts_with(strtolower($value), 'https://')) {
|
||||
Json::fail('bad_request', 'API 外网地址须以 https:// 开头', 400);
|
||||
}
|
||||
if (str_ends_with($key, '.sandbox')) {
|
||||
$value = in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true) ? '1' : '0';
|
||||
}
|
||||
if ($key === 'payment.display_channels') {
|
||||
$parsed = self::parseDisplayChannelsValue($value);
|
||||
if ($parsed === []) {
|
||||
Json::fail('bad_request', '至少选择一种前端展示支付方式', 400);
|
||||
}
|
||||
$value = implode(',', $parsed);
|
||||
}
|
||||
$stmt->execute(['k' => $key, 'v' => $value, 'ts' => $now]);
|
||||
$saved[] = $key;
|
||||
}
|
||||
if ($saved === []) {
|
||||
Json::fail('bad_request', '无有效配置项', 400);
|
||||
}
|
||||
AuditService::log($adminId, 'payment.config.save', 'payment', ['keys' => $saved]);
|
||||
self::reloadRuntime();
|
||||
return ['keys' => $saved];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function adminSnapshot(): array
|
||||
{
|
||||
$baseUrl = rtrim((string)Config::get('site.base_url', ''), '/');
|
||||
return [
|
||||
'params' => self::paramValues(),
|
||||
'display_channels' => self::displayChannels(),
|
||||
'readiness' => self::readiness(),
|
||||
'notify_urls' => [
|
||||
'alipay' => $baseUrl !== '' ? $baseUrl . '/api/v1/pay/alipay/notify' : '',
|
||||
'wechat' => $baseUrl !== '' ? $baseUrl . '/api/v1/pay/wechat/notify' : '',
|
||||
],
|
||||
'return_url_hint' => rtrim((string)Config::get('site.front_base_url', Config::get('site.base_url', '')), '/')
|
||||
. '/pages/member.web.html?paid={order_no}',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array{ready: bool, missing: list<string>}> */
|
||||
public static function readiness(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (self::REQUIRED as $channel => $keys) {
|
||||
$missing = [];
|
||||
foreach ($keys as $key) {
|
||||
if (!self::isPresent($key)) {
|
||||
$missing[] = self::missingLabel($key);
|
||||
}
|
||||
}
|
||||
$out[$channel] = ['ready' => $missing === [], 'missing' => $missing];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function isPresent(string $key): bool
|
||||
{
|
||||
$val = Config::get($key);
|
||||
if (is_bool($val)) {
|
||||
return true;
|
||||
}
|
||||
if (is_string($val)) {
|
||||
return trim($val) !== '';
|
||||
}
|
||||
return $val !== null && $val !== '';
|
||||
}
|
||||
|
||||
private static function missingLabel(string $key): string
|
||||
{
|
||||
if (isset(self::PARAM_FIELDS[$key])) {
|
||||
return self::PARAM_FIELDS[$key]['label'];
|
||||
}
|
||||
$map = [
|
||||
'alipay.private_key' => '应用私钥',
|
||||
'alipay.public_key' => '支付宝公钥',
|
||||
'wechat.mch_private_key' => '商户私钥',
|
||||
'wechat.api_v3_key' => 'APIv3 密钥',
|
||||
'wechat.platform_cert' => '平台证书',
|
||||
];
|
||||
return $map[$key] ?? $key;
|
||||
}
|
||||
|
||||
private static function stringValue(string $key, mixed $value): string
|
||||
{
|
||||
if (str_ends_with($key, '.sandbox')) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
if ($key === 'payment.display_channels') {
|
||||
if (is_array($value)) {
|
||||
return implode(',', self::parseDisplayChannelsList($value));
|
||||
}
|
||||
$parsed = self::parseDisplayChannelsValue((string)($value ?? ''));
|
||||
return $parsed !== [] ? implode(',', $parsed) : 'alipay';
|
||||
}
|
||||
return is_string($value) ? $value : (string)($value ?? '');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function parseDisplayChannelsValue(string $value): array
|
||||
{
|
||||
return self::parseDisplayChannelsList(
|
||||
preg_split('/[\s,]+/', trim($value), -1, PREG_SPLIT_NO_EMPTY) ?: []
|
||||
);
|
||||
}
|
||||
|
||||
/** @param list<string> $parts @return list<string> */
|
||||
private static function parseDisplayChannelsList(array $parts): array
|
||||
{
|
||||
$allowed = ['alipay', 'wechat'];
|
||||
$out = [];
|
||||
foreach ($parts as $p) {
|
||||
$p = strtolower(trim((string)$p));
|
||||
if ($p !== '' && in_array($p, $allowed, true) && !in_array($p, $out, true)) {
|
||||
$out[] = $p;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -38,4 +38,10 @@ final class PaymentConfigLoader
|
||||
$config[$section][$key] = $content;
|
||||
}
|
||||
}
|
||||
|
||||
public static function reload(array &$config): void
|
||||
{
|
||||
self::merge($config);
|
||||
SettingsConfigLoader::merge($config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 将 settings 表中白名单键覆盖到 Config(点号路径)。
|
||||
*/
|
||||
final class SettingsConfigLoader
|
||||
{
|
||||
/** @var list<string> */
|
||||
public const PAYMENT_KEYS = [
|
||||
'site.base_url',
|
||||
'site.front_base_url',
|
||||
'payment.display_channels',
|
||||
'alipay.app_id',
|
||||
'alipay.sandbox',
|
||||
'wechat.app_id',
|
||||
'wechat.mch_id',
|
||||
'wechat.mch_serial_no',
|
||||
'wechat.sandbox',
|
||||
];
|
||||
|
||||
public static function merge(array &$config): void
|
||||
{
|
||||
try {
|
||||
$stmt = Db::pdo()->query('SELECT `key`, `value` FROM settings');
|
||||
$rows = $stmt->fetchAll();
|
||||
} catch (Throwable) {
|
||||
return;
|
||||
}
|
||||
$allowed = array_flip(self::PAYMENT_KEYS);
|
||||
foreach ($rows as $row) {
|
||||
$key = (string)($row['key'] ?? '');
|
||||
if ($key === '' || !isset($allowed[$key])) {
|
||||
continue;
|
||||
}
|
||||
$value = (string)($row['value'] ?? '');
|
||||
self::setNested($config, $key, self::castValue($key, $value));
|
||||
}
|
||||
}
|
||||
|
||||
private static function castValue(string $key, string $value): mixed
|
||||
{
|
||||
if (str_ends_with($key, '.sandbox')) {
|
||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function setNested(array &$config, string $dotKey, mixed $value): void
|
||||
{
|
||||
$segments = explode('.', $dotKey);
|
||||
$ref = &$config;
|
||||
foreach ($segments as $i => $seg) {
|
||||
if ($i === count($segments) - 1) {
|
||||
$ref[$seg] = $value;
|
||||
return;
|
||||
}
|
||||
if (!isset($ref[$seg]) || !is_array($ref[$seg])) {
|
||||
$ref[$seg] = [];
|
||||
}
|
||||
$ref = &$ref[$seg];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
|
||||
/**
|
||||
* 扫描 models_dir 下 .soon 文件,解析名称、类型与缩略图。
|
||||
*/
|
||||
final class SoonModelService
|
||||
{
|
||||
private const META_HEAD_BYTES = 16384;
|
||||
private const META_TAIL_BYTES = 32768;
|
||||
|
||||
/** @return list<string> */
|
||||
public static function roots(): array
|
||||
{
|
||||
$candidates = [
|
||||
(string)Config::get('storage.models_dir', ''),
|
||||
SOON_SERVER_ROOT . '/soonModels',
|
||||
];
|
||||
$out = [];
|
||||
foreach ($candidates as $path) {
|
||||
$path = rtrim($path, '/\\');
|
||||
if ($path === '' || isset($out[$path])) {
|
||||
continue;
|
||||
}
|
||||
if (@is_dir($path) && @is_readable($path)) {
|
||||
$out[$path] = true;
|
||||
}
|
||||
}
|
||||
return array_keys($out);
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public static function list(): array
|
||||
{
|
||||
$apiBase = rtrim((string)Config::get('site.base_url', ''), '/');
|
||||
$items = [];
|
||||
$seen = [];
|
||||
foreach (self::roots() as $root) {
|
||||
foreach (self::scanSoonNames($root) as $base) {
|
||||
if (isset($seen[$base])) {
|
||||
continue;
|
||||
}
|
||||
$path = $root . DIRECTORY_SEPARATOR . $base;
|
||||
$item = self::buildItem($path, $base, $apiBase);
|
||||
if ($item !== null) {
|
||||
$seen[$base] = true;
|
||||
$items[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
usort($items, static fn(array $a, array $b): int => strnatcasecmp((string)$a['name'], (string)$b['name']));
|
||||
return $items;
|
||||
}
|
||||
|
||||
public static function resolvePath(string $filename): ?string
|
||||
{
|
||||
$base = basename(urldecode($filename));
|
||||
if ($base === '' || $base[0] === '.' || !self::isSoonName($base)) {
|
||||
return null;
|
||||
}
|
||||
foreach (self::roots() as $root) {
|
||||
$path = $root . DIRECTORY_SEPARATOR . $base;
|
||||
if (@is_file($path) && @is_readable($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function scanSoonNames(string $root): array
|
||||
{
|
||||
$entries = @scandir($root);
|
||||
if ($entries === false) {
|
||||
return [];
|
||||
}
|
||||
$names = [];
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || $entry === '' || $entry[0] === '.') {
|
||||
continue;
|
||||
}
|
||||
if (!self::isSoonName($entry)) {
|
||||
continue;
|
||||
}
|
||||
$path = $root . DIRECTORY_SEPARATOR . $entry;
|
||||
if (@is_file($path)) {
|
||||
$names[] = $entry;
|
||||
}
|
||||
}
|
||||
return $names;
|
||||
}
|
||||
|
||||
private static function isSoonName(string $name): bool
|
||||
{
|
||||
return str_ends_with(strtolower($name), '.soon');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
private static function buildItem(string $path, string $base, string $apiBase): ?array
|
||||
{
|
||||
$size = @filesize($path);
|
||||
if ($size === false || $size <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = self::readMetadata((int)$size, $path);
|
||||
$name = pathinfo($base, PATHINFO_FILENAME);
|
||||
if ($meta['title'] !== '') {
|
||||
$name = $meta['title'];
|
||||
} elseif ($meta['name'] !== '') {
|
||||
$name = $meta['name'];
|
||||
}
|
||||
|
||||
$fileUrl = ($apiBase !== '' ? $apiBase : '') . '/api/v1/soon-models/files/' . rawurlencode($base);
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'type' => $meta['type'],
|
||||
'file' => $base,
|
||||
'file_url' => $fileUrl,
|
||||
'size' => (int)$size,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title:string,name:string,type:int}
|
||||
*/
|
||||
private static function readMetadata(int $size, string $path): array
|
||||
{
|
||||
$head = self::readBytes($path, 0, min(self::META_HEAD_BYTES, $size));
|
||||
$tail = $size > self::META_HEAD_BYTES
|
||||
? self::readBytes($path, max(0, $size - self::META_TAIL_BYTES), min(self::META_TAIL_BYTES, $size))
|
||||
: '';
|
||||
$raw = $head . $tail;
|
||||
|
||||
$type = 1;
|
||||
if (preg_match('/"soonType"\s*:\s*(\d+)/', $raw, $m)) {
|
||||
$type = (int)$m[1] === 2 ? 2 : 1;
|
||||
} elseif (preg_match('/"backBlackPic"\s*:\s*"(?!")/', $raw)) {
|
||||
$type = 2;
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => self::matchJsonString($raw, 'title'),
|
||||
'name' => self::matchJsonString($raw, 'name'),
|
||||
'type' => $type,
|
||||
];
|
||||
}
|
||||
|
||||
private static function readBytes(string $path, int $offset, int $length): string
|
||||
{
|
||||
if ($length <= 0) {
|
||||
return '';
|
||||
}
|
||||
$fh = @fopen($path, 'rb');
|
||||
if ($fh === false) {
|
||||
return '';
|
||||
}
|
||||
if ($offset > 0) {
|
||||
fseek($fh, $offset);
|
||||
}
|
||||
$data = fread($fh, $length);
|
||||
fclose($fh);
|
||||
return is_string($data) ? $data : '';
|
||||
}
|
||||
|
||||
private static function matchJsonString(string $raw, string $key): string
|
||||
{
|
||||
if (!preg_match('/"' . preg_quote($key, '/') . '"\s*:\s*"((?:\\\\.|[^"\\\\])*)"/u', $raw, $m)) {
|
||||
return '';
|
||||
}
|
||||
$decoded = json_decode('"' . $m[1] . '"');
|
||||
return is_string($decoded) ? trim($decoded) : '';
|
||||
}
|
||||
}
|
||||
@@ -93,5 +93,6 @@ spl_autoload_register(static function (string $class): void {
|
||||
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);
|
||||
require SOON_API_ROOT . '/Services/SettingsConfigLoader.php';
|
||||
\Soon\Api\Services\PaymentConfigLoader::reload($config);
|
||||
\Soon\Api\Core\Config::init($config);
|
||||
|
||||
Reference in New Issue
Block a user