前端配置迁至 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:
@@ -17,7 +17,8 @@ alwaysApply: true
|
||||
- **继承上下文**:结合整段对话;最新一条默认延续当前任务,区分细化与换方向。
|
||||
- **回复**:简体中文;标识符与代码保持原文。结构:**结论** → **要点** → **命令**(若有)。少寒暄、少加粗;禁「保证/一定/完美/彻底解决」。
|
||||
- **引用代码**:独占一行 `startLine:endLine:filepath`,行号与路径须真实。
|
||||
- **中文 UTF-8**:含中文文件 UTF-8 无 BOM;灌库只用 `scripts/apply-mysql-utf8.ps1`(禁 `type`/管道灌库);`bat` 不写中文 SQL。
|
||||
- **中文 UTF-8**:含中文文件 UTF-8 无 BOM;灌库用 `mysql` 导入 `backend-web/schema.sql`(禁 `type`/管道灌库);`bat` 不写中文 SQL。
|
||||
- **本仓库配置**:后端 `backend-web/config/local.php`;前端 `frontend-web/config/local.js`(gitignore,全量更新勿覆盖;页面直接引该文件,设置 `SOON_DEPLOY_CONFIG`)。
|
||||
|
||||
## 改动
|
||||
|
||||
|
||||
+3
-1
@@ -6,9 +6,10 @@ npm-debug.log*
|
||||
dist/*.zip
|
||||
_salvage/
|
||||
backend-web/config/local.php
|
||||
frontend-web/config/local.js
|
||||
docker/.env
|
||||
docker/config/local.php
|
||||
docker/config/deploy-config.js
|
||||
docker/config/local.js
|
||||
soonModels/*.soon
|
||||
backend-web/storage/payment/*
|
||||
!backend-web/storage/**/.gitkeep
|
||||
@@ -17,3 +18,4 @@ backend-web/storage/payment/*
|
||||
*.bak
|
||||
*.pem
|
||||
*.key
|
||||
/soonDesign-back
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
需 [Docker Desktop](https://www.docker.com/products/docker-desktop/)。
|
||||
|
||||
```bat
|
||||
docker\sync-config.ps1 :: 同步 API 地址到 deploy-config.js 与 backend local.php(改端口后必跑)
|
||||
docker\sync-config.ps1 :: 同步 local.js 与 backend local.php(改端口后必跑)
|
||||
scripts\start.bat :: 启动
|
||||
scripts\start.bat stop :: 停止(保留数据库)
|
||||
scripts\start.bat reset :: 停止并清空数据库卷
|
||||
```
|
||||
|
||||
`frontend-web/assets/deploy-config.js` 由 `docker/sync-config.ps1` 根据 `docker/.env` 端口生成,请勿手改 localhost 后提交;生产环境在部署时写入实际 API 地址。
|
||||
生产环境编辑 `frontend-web/config/local.js`(从 `config/local.js.example` 复制);全量更新 frontend 时不要覆盖该文件。本地开发由 `docker/sync-config.ps1` 生成 `local.js`。
|
||||
|
||||
| 服务 | 默认地址 |
|
||||
|------|----------|
|
||||
@@ -44,7 +44,7 @@ scripts\start.bat reset :: 停止并清空数据库卷
|
||||
- PHP 根目录:`backend-web/public/`
|
||||
- 数据库:导入 `backend-web/schema.sql`
|
||||
- 后端:`backend-web/config/local.php.example` → `local.php`
|
||||
- 前端 API:`frontend-web/assets/deploy-config.js`
|
||||
- 前端 API:`frontend-web/config/local.js`(生产配置,勿覆盖)
|
||||
|
||||
支付与 notify 说明见 [docs/PAYMENT.md](docs/PAYMENT.md)。列表分页契约见 [docs/API-PAGINATION.md](docs/API-PAGINATION.md)。
|
||||
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
$data = json_decode((string)file_get_contents($manifest), true);
|
||||
if (!is_array($data)) {
|
||||
Json::fail('manifest_invalid', '模型清单已损坏', 500);
|
||||
|
||||
public function download(string $name): void
|
||||
{
|
||||
$path = SoonModelService::resolvePath($name);
|
||||
if ($path === null) {
|
||||
Json::fail('not_found', '模板不存在', 404);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
volumes:
|
||||
- ../frontend-web:/var/www/web:ro
|
||||
- ../frontend-electron:/var/www/electron:ro
|
||||
- ./config/deploy-config.js:/var/www/web/assets/deploy-config.js:ro
|
||||
- ./config/local.js:/var/www/web/config/local.js:ro
|
||||
- ./nginx/frontend.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
@@ -8,7 +8,7 @@ Get-Content $EnvFile -Encoding UTF8 | ForEach-Object {
|
||||
}
|
||||
$w, $e, $b = $ports.WEB_PORT, $ports.ELECTRON_PORT, $ports.BACKEND_PORT
|
||||
|
||||
$deployJs = @"
|
||||
$localJs = @"
|
||||
window.SOON_DEPLOY_CONFIG = {
|
||||
api_v1_base: 'http://localhost:$b/api/v1',
|
||||
api_admin_base: 'http://localhost:$b/api/admin',
|
||||
@@ -63,8 +63,9 @@ return [
|
||||
$dir = Join-Path $PSScriptRoot 'config'
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
$enc = [Text.UTF8Encoding]::new($false)
|
||||
[IO.File]::WriteAllText((Join-Path $dir 'deploy-config.js'), $deployJs.Trim() + "`n", $enc)
|
||||
[IO.File]::WriteAllText((Join-Path $dir 'local.js'), $localJs.Trim() + "`n", $enc)
|
||||
[IO.File]::WriteAllText((Join-Path $dir 'local.php'), $localPhp.Trim() + "`n", $enc)
|
||||
|
||||
$webDeploy = Join-Path $Root 'frontend-web\assets\deploy-config.js'
|
||||
[IO.File]::WriteAllText($webDeploy, $deployJs.Trim() + "`n", $enc)
|
||||
$webLocal = Join-Path $Root 'frontend-web\config\local.js'
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $webLocal -Parent) | Out-Null
|
||||
[IO.File]::WriteAllText($webLocal, $localJs.Trim() + "`n", $enc)
|
||||
|
||||
@@ -163,7 +163,7 @@ GET /api/v1/pay/orders?page=1&size=8
|
||||
| `GET /api/v1/plans` | `{ items: [...] }` 全量活跃套餐 |
|
||||
| `GET /api/v1/plans/me` | 会员信息 + `recent_orders`(默认 8 条,非翻页) |
|
||||
| `GET /api/v1/settings` | 扁平 key-value 对象,非 `items` 数组 |
|
||||
| `GET /api/v1/soon-models` | manifest 内容 |
|
||||
| `GET /api/v1/soon-models` | 扫描 `models_dir` 下 `.soon` 自动生成的列表 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+16
-3
@@ -1,6 +1,19 @@
|
||||
# 支付联调
|
||||
|
||||
## 密钥文件(Admin → 支付密钥 或 `backend-web/storage/payment/`)
|
||||
## Admin 支付配置(推荐)
|
||||
|
||||
管理后台 **支付配置** 页可一次性维护:
|
||||
|
||||
| 类别 | 项 |
|
||||
|------|-----|
|
||||
| 站点 | `site.base_url`(HTTPS 外网)、`site.front_base_url` |
|
||||
| 前端展示 | `payment.display_channels`(默认 `alipay`;可 `alipay,wechat`) |
|
||||
| 支付宝 | `app_id`、沙箱开关、`private_key`、`public_key` |
|
||||
| 微信 | `app_id`、`mch_id`、`mch_serial_no`、沙箱标记、`mch_private_key`、`api_v3_key`、`platform_cert` |
|
||||
|
||||
保存参数写入 `settings` 表并热加载;密钥上传至 `backend-web/storage/payment/*.pem`。页顶就绪状态可查看缺项。
|
||||
|
||||
## 密钥文件(亦可直接放入 `storage/payment/`)
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
@@ -10,9 +23,9 @@
|
||||
| wechat_api_v3_key.pem | APIv3 密钥(32 字节) |
|
||||
| wechat_platform_cert.pem | 微信平台证书 |
|
||||
|
||||
## local.php 必填项
|
||||
## local.php 默认值
|
||||
|
||||
`site.base_url`(HTTPS 外网)、`alipay.app_id`、`wechat.mch_id`、`wechat.mch_serial_no`
|
||||
`local.php` / `local.php.example` 可预填上述项;Admin 中保存的值会覆盖同键配置。
|
||||
|
||||
## notify 路径
|
||||
|
||||
|
||||
@@ -966,6 +966,34 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.soon-pay-channels--single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.soon-pay-channel.is-fixed {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.soon-pay-channel.is-fixed:hover {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.soon-pay-channel.is-fixed.is-active:hover {
|
||||
border-color: var(--soon-accent);
|
||||
background: rgba(0, 150, 136, 0.07);
|
||||
}
|
||||
|
||||
.soon-pay-channel.is-fixed .soon-pay-channel__radio {
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.soon-pay-channel.is-fixed.is-active .soon-pay-channel__radio::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.soon-pay-channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -271,44 +271,12 @@
|
||||
animation: soon-fadeInUp 0.4s;
|
||||
}
|
||||
.soon-member-wrap { max-width: 960px; margin: 0 auto; }
|
||||
.soon-template-card {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: 148px;
|
||||
background: var(--soon-bg-panel);
|
||||
border: 1px solid var(--soon-border-subtle);
|
||||
border-radius: var(--soon-radius-sm);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s var(--soon-ease), border-color 0.2s, box-shadow 0.2s;
|
||||
animation: soon-fadeInUp 0.4s var(--soon-ease) backwards;
|
||||
}
|
||||
|
||||
.soon-templates-strip .soon-empty--inline {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
.soon-template-card:hover {
|
||||
border-color: var(--soon-accent);
|
||||
background: var(--soon-bg-elevated);
|
||||
}
|
||||
.soon-template-card img {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
background: var(--soon-bg-deep);
|
||||
}
|
||||
.soon-template-card span {
|
||||
display: block;
|
||||
padding: var(--soon-space-sm);
|
||||
font-size: 12px;
|
||||
color: var(--soon-text);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
.soon-member-header {
|
||||
@@ -506,9 +474,12 @@
|
||||
}
|
||||
|
||||
.soon-home .card-list > .card,
|
||||
.soon-home-recent-grid > .card {
|
||||
.soon-home-recent-grid > .card,
|
||||
.soon-home .soon-templates-strip > .card {
|
||||
width: 168px;
|
||||
max-width: 168px;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.soon-section--recent #filePager {
|
||||
@@ -518,7 +489,8 @@
|
||||
border-top: 1px solid var(--soon-border-subtle);
|
||||
}
|
||||
|
||||
.soon-home .card-list .rect {
|
||||
.soon-home .card-list .rect,
|
||||
.soon-home .soon-templates-strip .rect {
|
||||
width: 168px;
|
||||
height: 160px;
|
||||
background: var(--soon-bg-panel);
|
||||
@@ -535,11 +507,13 @@
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.soon-home .card-list .rect:hover {
|
||||
.soon-home .card-list .rect:hover,
|
||||
.soon-home .soon-templates-strip .rect:hover {
|
||||
border-color: var(--soon-accent);
|
||||
background: var(--soon-bg-elevated);
|
||||
}
|
||||
.soon-home .card-list .rect img {
|
||||
.soon-home .card-list .rect img,
|
||||
.soon-home .soon-templates-strip .rect img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 135px;
|
||||
@@ -553,9 +527,12 @@
|
||||
border-radius: 4px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.soon-home .card-list .rect1 img { transform: scale(0.8); }
|
||||
.soon-home .card-list .rect1:hover img { transform: scale(0.85); }
|
||||
.soon-home .card-list .tip {
|
||||
.soon-home .card-list .rect1 img,
|
||||
.soon-home .soon-templates-strip .rect1 img { transform: scale(0.8); }
|
||||
.soon-home .card-list .rect1:hover img,
|
||||
.soon-home .soon-templates-strip .rect1:hover img { transform: scale(0.85); }
|
||||
.soon-home .card-list .tip,
|
||||
.soon-home .soon-templates-strip .tip {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
height: 25px;
|
||||
@@ -571,7 +548,8 @@
|
||||
border-top: 1px solid var(--soon-border-subtle);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.soon-home .card-list .rect:hover .tip { color: var(--soon-text-strong); }
|
||||
.soon-home .card-list .rect:hover .tip,
|
||||
.soon-home .soon-templates-strip .rect:hover .tip { color: var(--soon-text-strong); }
|
||||
.soon-home .card-list .soon-file-card__actions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -636,7 +614,8 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.soon-home .card-list .rect:not(.rect1)::after {
|
||||
.soon-home .card-list .rect:not(.rect1)::after,
|
||||
.soon-home .soon-templates-strip .rect:not(.rect1)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
@@ -736,18 +715,22 @@
|
||||
min-height: 300px;
|
||||
}
|
||||
.soon-home .card-list > .card,
|
||||
.soon-home-recent-grid > .card {
|
||||
.soon-home-recent-grid > .card,
|
||||
.soon-home .soon-templates-strip > .card {
|
||||
width: 148px;
|
||||
max-width: 148px;
|
||||
}
|
||||
.soon-home .card-list .rect {
|
||||
.soon-home .card-list .rect,
|
||||
.soon-home .soon-templates-strip .rect {
|
||||
width: 148px;
|
||||
height: 150px;
|
||||
}
|
||||
.soon-home .card-list .rect img {
|
||||
.soon-home .card-list .rect img,
|
||||
.soon-home .soon-templates-strip .rect img {
|
||||
height: 125px;
|
||||
}
|
||||
.soon-home .card-list .rect:not(.rect1)::after {
|
||||
.soon-home .card-list .rect:not(.rect1)::after,
|
||||
.soon-home .soon-templates-strip .rect:not(.rect1)::after {
|
||||
transform: translate(-50%, calc(-50% - 12px));
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
window.SOON_DEPLOY_CONFIG = {
|
||||
api_v1_base: 'http://localhost:8102/api/v1',
|
||||
api_admin_base: 'http://localhost:8102/api/admin',
|
||||
front_base_url: 'http://localhost:8100',
|
||||
web_base: 'http://localhost:8100',
|
||||
version: 'local-dev'
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"api_v1_base": "https://designadmin.cardsoon.com/api/v1",
|
||||
"api_admin_base": "https://designadmin.cardsoon.com/api/admin",
|
||||
"front_base_url": "https://design.cardsoon.com",
|
||||
"web_base": "https://design.cardsoon.com",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// 复制为同目录 local.js(全量更新 frontend 时不要覆盖 local.js)
|
||||
window.SOON_DEPLOY_CONFIG = {
|
||||
api_v1_base: 'https://designadmin.cardsoon.com/api/v1',
|
||||
api_admin_base: 'https://designadmin.cardsoon.com/api/admin',
|
||||
front_base_url: 'https://design.cardsoon.com',
|
||||
web_base: 'https://design.cardsoon.com',
|
||||
version: '1.0.0'
|
||||
};
|
||||
@@ -3,6 +3,13 @@
|
||||
|
||||
var POLL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
var PAY_STEP_LABELS = ['选择支付', '扫码付款', '订阅生效'];
|
||||
var _displayChannels = ['alipay'];
|
||||
var _settingsPromise = null;
|
||||
|
||||
var CHANNEL_UI = {
|
||||
alipay: { name: '支付宝', hint: '网页支付', icon: '支', iconCls: 'alipay' },
|
||||
wechat: { name: '微信支付', hint: '扫码付款', icon: '微', iconCls: 'wechat' },
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
@@ -92,6 +99,83 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseDisplayChannels(raw) {
|
||||
if (Array.isArray(raw)) {
|
||||
var list = raw.filter(function (ch) { return ch === 'alipay' || ch === 'wechat'; });
|
||||
return list.length ? list : ['alipay'];
|
||||
}
|
||||
var parts = String(raw || 'alipay').split(/[\s,]+/).filter(Boolean);
|
||||
var out = [];
|
||||
parts.forEach(function (ch) {
|
||||
if ((ch === 'alipay' || ch === 'wechat') && out.indexOf(ch) < 0) out.push(ch);
|
||||
});
|
||||
return out.length ? out : ['alipay'];
|
||||
}
|
||||
|
||||
function loadDisplayChannels() {
|
||||
if (_settingsPromise) return _settingsPromise;
|
||||
_settingsPromise = apiGet('/settings').then(function (j) {
|
||||
if (j.ok && j.data) {
|
||||
if (j.data.payment_display_channels) {
|
||||
_displayChannels = parseDisplayChannels(j.data.payment_display_channels);
|
||||
} else if (j.data['payment.display_channels'] != null) {
|
||||
_displayChannels = parseDisplayChannels(j.data['payment.display_channels']);
|
||||
}
|
||||
}
|
||||
return _displayChannels;
|
||||
}).catch(function () {
|
||||
_displayChannels = ['alipay'];
|
||||
return _displayChannels;
|
||||
});
|
||||
return _settingsPromise;
|
||||
}
|
||||
|
||||
function displayChannels() {
|
||||
return _displayChannels.slice();
|
||||
}
|
||||
|
||||
function defaultChannel(channels) {
|
||||
var list = channels || _displayChannels;
|
||||
return list[0] || 'alipay';
|
||||
}
|
||||
|
||||
function resolveChannel(requested, channels) {
|
||||
var list = channels || _displayChannels;
|
||||
if (requested && list.indexOf(requested) >= 0) return requested;
|
||||
return defaultChannel(list);
|
||||
}
|
||||
|
||||
function channelCardHtml(ch, active, selectable) {
|
||||
var ui = CHANNEL_UI[ch];
|
||||
if (!ui) return '';
|
||||
var cls = 'soon-pay-channel' + (active ? ' is-active' : '') + (selectable ? '' : ' is-fixed');
|
||||
var inner = '<span class="soon-pay-channel__radio" aria-hidden="true"></span>' +
|
||||
'<span class="soon-pay-channel__icon soon-pay-channel__icon--' + ui.iconCls + '">' + ui.icon + '</span>' +
|
||||
'<span class="soon-pay-channel__text"><span class="soon-pay-channel__name">' + ui.name + '</span>' +
|
||||
'<span class="soon-pay-channel__hint">' + ui.hint + '</span></span>';
|
||||
if (selectable) {
|
||||
return '<button type="button" class="' + cls + '" data-ch="' + ch + '">' + inner + '</button>';
|
||||
}
|
||||
return '<div class="' + cls + '" data-ch="' + ch + '">' + inner + '</div>';
|
||||
}
|
||||
|
||||
function payChannelsHtml(activeChannel, channels, opts) {
|
||||
opts = opts || {};
|
||||
channels = channels || _displayChannels;
|
||||
var showChannel = activeChannel || defaultChannel(channels);
|
||||
var selectable = opts.selectable !== false && channels.length > 1;
|
||||
var list = selectable ? channels : [showChannel];
|
||||
var cards = list.map(function (ch) {
|
||||
return channelCardHtml(ch, ch === showChannel, selectable);
|
||||
}).join('');
|
||||
if (!cards) return '';
|
||||
return '<section class="soon-pay-section soon-pay-section--channels' +
|
||||
(selectable ? '' : ' soon-pay-section--channels-fixed') + '" id="payChannels">' +
|
||||
'<h4 class="soon-pay-section__title">支付方式</h4>' +
|
||||
'<div class="soon-pay-channels' + (list.length === 1 ? ' soon-pay-channels--single' : '') + '">' +
|
||||
cards + '</div></section>';
|
||||
}
|
||||
|
||||
function payStepLabel(step, channel) {
|
||||
if (step === 2 && channel === 'alipay') return '网页支付';
|
||||
if (step === 2 && channel === 'wechat') return '扫码付款';
|
||||
@@ -118,19 +202,20 @@
|
||||
|
||||
function updatePayStep(root, step, channel) {
|
||||
if (!root) return;
|
||||
var html = payStepperHtml(step, channel || 'wechat');
|
||||
var html = payStepperHtml(step, channel || defaultChannel());
|
||||
var old = root.querySelector('#payStepper');
|
||||
if (old) old.outerHTML = html;
|
||||
}
|
||||
|
||||
function payEmptyStateHtml() {
|
||||
function payEmptyStateHtml(channel) {
|
||||
var ui = CHANNEL_UI[channel] || CHANNEL_UI[defaultChannel()];
|
||||
return '<div class="soon-pay-checkout-empty">' +
|
||||
'<div class="soon-pay-checkout-empty__icon" aria-hidden="true">' +
|
||||
'<svg viewBox="0 0 48 48" fill="none"><rect x="8" y="12" width="32" height="24" rx="4" stroke="currentColor" stroke-width="1.5"/>' +
|
||||
'<path d="M8 20h32" stroke="currentColor" stroke-width="1.5"/>' +
|
||||
'<circle cx="32" cy="30" r="3" fill="currentColor" opacity="0.5"/></svg></div>' +
|
||||
'<p class="soon-pay-checkout-empty__title">等待生成付款码</p>' +
|
||||
'<p class="soon-pay-checkout-empty__desc">确认支付方式后,将在此显示二维码或跳转链接</p></div>';
|
||||
'<p class="soon-pay-checkout-empty__title">等待生成付款信息</p>' +
|
||||
'<p class="soon-pay-checkout-empty__desc">点击下方按钮,将使用' + esc(ui.name) + '(' + esc(ui.hint) + ')</p></div>';
|
||||
}
|
||||
|
||||
function renderPayPayload(data, qrEl, orderNoEl) {
|
||||
@@ -195,7 +280,10 @@
|
||||
function paySheetHtml(opts) {
|
||||
opts = opts || {};
|
||||
var resumeOrderNo = opts.orderNo || null;
|
||||
var channel = opts.channel || 'wechat';
|
||||
var channels = opts.displayChannels || _displayChannels;
|
||||
var channel = (resumeOrderNo && opts.channel)
|
||||
? opts.channel
|
||||
: resolveChannel(opts.channel, channels);
|
||||
var embedded = opts.variant === 'embedded';
|
||||
var backBtn = !embedded && opts.showBack
|
||||
? '<button type="button" class="soon-btn soon-btn--ghost soon-btn--sm soon-pay-sheet__back" id="payBack">返回选择方案</button>'
|
||||
@@ -222,21 +310,9 @@
|
||||
'<div class="soon-pay-sheet__body">' +
|
||||
orderInBody +
|
||||
payStepperHtml(1, channel) +
|
||||
'<section class="soon-pay-section soon-pay-section--channels" id="payChannels">' +
|
||||
'<h4 class="soon-pay-section__title">支付方式</h4>' +
|
||||
'<div class="soon-pay-channels">' +
|
||||
'<button type="button" class="soon-pay-channel' + (channel === 'wechat' ? ' is-active' : '') + '" data-ch="wechat">' +
|
||||
'<span class="soon-pay-channel__radio" aria-hidden="true"></span>' +
|
||||
'<span class="soon-pay-channel__icon soon-pay-channel__icon--wechat">微</span>' +
|
||||
'<span class="soon-pay-channel__text"><span class="soon-pay-channel__name">微信支付</span>' +
|
||||
'<span class="soon-pay-channel__hint">扫码付款</span></span></button>' +
|
||||
'<button type="button" class="soon-pay-channel' + (channel === 'alipay' ? ' is-active' : '') + '" data-ch="alipay">' +
|
||||
'<span class="soon-pay-channel__radio" aria-hidden="true"></span>' +
|
||||
'<span class="soon-pay-channel__icon soon-pay-channel__icon--alipay">支</span>' +
|
||||
'<span class="soon-pay-channel__text"><span class="soon-pay-channel__name">支付宝</span>' +
|
||||
'<span class="soon-pay-channel__hint">网页支付</span></span></button></div></section>' +
|
||||
payChannelsHtml(channel, channels, { selectable: !resumeOrderNo && channels.length > 1 }) +
|
||||
'<section class="soon-pay-section soon-pay-section--checkout">' +
|
||||
'<div class="soon-pay-qr-panel" id="payQr">' + payEmptyStateHtml() + '</div></section>' +
|
||||
'<div class="soon-pay-qr-panel" id="payQr">' + payEmptyStateHtml(channel) + '</div></section>' +
|
||||
'<div class="soon-pay-sheet__foot">' +
|
||||
'<p class="soon-pay-status" id="payStatus"></p>' +
|
||||
'<button type="button" class="soon-btn soon-pay-sheet__cta" id="payConfirm">' +
|
||||
@@ -249,7 +325,10 @@
|
||||
opts = opts || {};
|
||||
var planId = opts.planId;
|
||||
var resumeOrderNo = opts.orderNo || null;
|
||||
var channel = opts.channel || 'wechat';
|
||||
var channels = opts.displayChannels || _displayChannels;
|
||||
var channel = (resumeOrderNo && opts.channel)
|
||||
? opts.channel
|
||||
: resolveChannel(opts.channel, channels);
|
||||
var onPaid = typeof opts.onPaid === 'function' ? opts.onPaid : function () {};
|
||||
var pollTimer = null;
|
||||
var pollStartedAt = 0;
|
||||
@@ -321,7 +400,7 @@
|
||||
setStep(1);
|
||||
if (manualBtn) manualBtn.style.display = 'none';
|
||||
if (confirmBtn) confirmBtn.style.display = '';
|
||||
qrEl.innerHTML = payEmptyStateHtml();
|
||||
qrEl.innerHTML = payEmptyStateHtml(channel);
|
||||
if (orderNoEl && !resumeOrderNo) {
|
||||
orderNoEl.textContent = '';
|
||||
orderNoEl.style.display = 'none';
|
||||
@@ -360,18 +439,18 @@
|
||||
if (confirmBtn) confirmBtn.disabled = false;
|
||||
if (!j.ok) {
|
||||
setStatus(j.message || '下单失败', 'err');
|
||||
qrEl.innerHTML = payEmptyStateHtml();
|
||||
qrEl.innerHTML = payEmptyStateHtml(channel);
|
||||
return;
|
||||
}
|
||||
applyCheckout(j.data);
|
||||
}).catch(function () {
|
||||
if (confirmBtn) confirmBtn.disabled = false;
|
||||
setStatus('网络错误,请重试', 'err');
|
||||
qrEl.innerHTML = payEmptyStateHtml();
|
||||
qrEl.innerHTML = payEmptyStateHtml(channel);
|
||||
});
|
||||
}
|
||||
|
||||
sheetRoot.querySelectorAll('.soon-pay-channel').forEach(function (el) {
|
||||
sheetRoot.querySelectorAll('.soon-pay-channel:not(.is-fixed)').forEach(function (el) {
|
||||
el.onclick = function () {
|
||||
if (el.classList.contains('is-active')) return;
|
||||
sheetRoot.querySelectorAll('.soon-pay-channel').forEach(function (x) { x.classList.remove('is-active'); });
|
||||
@@ -399,11 +478,16 @@
|
||||
function openPayModal(opts) {
|
||||
opts = opts || {};
|
||||
if (typeof layer === 'undefined' || !layer.open) return null;
|
||||
var channels = _displayChannels;
|
||||
var channel = (opts.orderNo && opts.channel)
|
||||
? opts.channel
|
||||
: resolveChannel(opts.channel, channels);
|
||||
var html = paySheetHtml({
|
||||
planName: opts.planName,
|
||||
priceDisplay: opts.priceDisplay,
|
||||
orderNo: opts.orderNo,
|
||||
channel: opts.channel || 'wechat',
|
||||
channel: channel,
|
||||
displayChannels: channels,
|
||||
});
|
||||
var payCtrl = null;
|
||||
var idx = layer.open({
|
||||
@@ -430,7 +514,8 @@
|
||||
planName: opts.planName,
|
||||
priceDisplay: opts.priceDisplay,
|
||||
orderNo: opts.orderNo,
|
||||
channel: opts.channel || 'wechat',
|
||||
channel: channel,
|
||||
displayChannels: channels,
|
||||
onPaid: function () {
|
||||
layer.close(idx);
|
||||
if (typeof opts.onPaid === 'function') opts.onPaid();
|
||||
@@ -446,6 +531,8 @@
|
||||
return idx;
|
||||
}
|
||||
|
||||
loadDisplayChannels();
|
||||
|
||||
window.SoonMemberPay = {
|
||||
apiBase: apiBase,
|
||||
apiGet: apiGet,
|
||||
@@ -459,6 +546,10 @@
|
||||
payEmptyStateHtml: payEmptyStateHtml,
|
||||
renderPayPayload: renderPayPayload,
|
||||
submitAlipayForm: submitAlipayForm,
|
||||
loadDisplayChannels: loadDisplayChannels,
|
||||
displayChannels: displayChannels,
|
||||
defaultChannel: defaultChannel,
|
||||
resolveChannel: resolveChannel,
|
||||
POLL_TIMEOUT_MS: POLL_TIMEOUT_MS,
|
||||
};
|
||||
})();
|
||||
|
||||
+85
-13
@@ -425,24 +425,92 @@ layui.use(['layer', 'form', 'jquery'], function () {
|
||||
|
||||
}
|
||||
|
||||
function templateCardHtml(m, i) {
|
||||
var defaultThumb = soonAsset('bg_1.png');
|
||||
var thumbSrc = typeof soonSafeImageUrl === 'function'
|
||||
? soonSafeImageUrl(m.thumbnail_url, defaultThumb)
|
||||
: (m.thumbnail_url || defaultThumb);
|
||||
if (!thumbSrc) thumbSrc = defaultThumb;
|
||||
function templateFallbackThumb(type) {
|
||||
var t = Number(type) || 1;
|
||||
return soonAsset(t === 2 ? 'bg_2.png' : 'bg_1.png');
|
||||
}
|
||||
|
||||
async function resolveTemplateItem(m) {
|
||||
var type = Number(m.type) || 1;
|
||||
var fallbackThumb = templateFallbackThumb(type);
|
||||
var fileUrl = m.file_url || '';
|
||||
var out = {
|
||||
name: m.name || m.title || '模板',
|
||||
type: type,
|
||||
file_url: fileUrl,
|
||||
thumbSrc: fallbackThumb,
|
||||
_soonData: null
|
||||
};
|
||||
if (!fileUrl) return out;
|
||||
try {
|
||||
var r = await fetch(fileUrl);
|
||||
if (!r.ok) throw new Error('fetch');
|
||||
var soonData = await r.json();
|
||||
if (!soonData) return out;
|
||||
out._soonData = soonData;
|
||||
type = soonData.soonType ? soonData.soonType : (soonData.backBlackPic ? 2 : 1);
|
||||
out.type = type;
|
||||
fallbackThumb = templateFallbackThumb(type);
|
||||
var src = typeof soonSafeImageUrl === 'function'
|
||||
? soonSafeImageUrl(soonData.frontDisplayPic, fallbackThumb)
|
||||
: (soonData.frontDisplayPic || fallbackThumb);
|
||||
out.thumbSrc = src || fallbackThumb;
|
||||
} catch (e) { /* 与最近文件一致:失败时用默认图 */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
function openTemplateItem(m) {
|
||||
var type = Number(m.type) || 1;
|
||||
function openWithJson(j) {
|
||||
if (!j) {
|
||||
openDesign(type, '');
|
||||
return;
|
||||
}
|
||||
var key = 'soondesign_session:tpl-' + Date.now();
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(j));
|
||||
openDesign(type, key);
|
||||
} catch (e) {
|
||||
openDesign(type, '');
|
||||
}
|
||||
}
|
||||
if (m._soonData) {
|
||||
openWithJson(m._soonData);
|
||||
return;
|
||||
}
|
||||
var fileUrl = m.file_url || '';
|
||||
if (!fileUrl) {
|
||||
openDesign(type, '');
|
||||
return;
|
||||
}
|
||||
fetch(fileUrl).then(function (r) {
|
||||
if (!r.ok) throw new Error('fetch');
|
||||
return r.json();
|
||||
}).then(openWithJson).catch(function () {
|
||||
openDesign(type, '');
|
||||
});
|
||||
}
|
||||
|
||||
function templateCardHtml(m, itemIndex) {
|
||||
var type = Number(m.type) || 1;
|
||||
var thumbSrc = m.thumbSrc || templateFallbackThumb(type);
|
||||
var esc = typeof soonEscapeHtml === 'function' ? soonEscapeHtml : escapeAttr;
|
||||
var name = esc(m.name || m.title || '模板');
|
||||
return '<div class="soon-template-card" style="animation-delay:' + (i * 0.05) + 's" data-type="' + (m.type || 1) + '">' +
|
||||
var rectClass = 'rect' + (type == 2 ? ' rect1' : '');
|
||||
return '<div class="card" data-type="' + type + '" data-index="' + itemIndex + '" title="' + name + '">' +
|
||||
'<div class="' + rectClass + '">' +
|
||||
'<img src="' + esc(thumbSrc) + '" alt="">' +
|
||||
'<span>' + name + '</span></div>';
|
||||
'<div class="tip">' + name + '</div>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function bindTemplateCards(grid) {
|
||||
if (!grid) return;
|
||||
grid.querySelectorAll('.soon-template-card').forEach(function (card) {
|
||||
grid.querySelectorAll('.card[data-index]').forEach(function (card) {
|
||||
card.onclick = function () {
|
||||
openDesign(Number(card.dataset.type) || 1, '');
|
||||
var idx = Number(card.dataset.index);
|
||||
var m = templateListState.items[idx];
|
||||
if (m) openTemplateItem(m);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -482,7 +550,7 @@ layui.use(['layer', 'form', 'jquery'], function () {
|
||||
|
||||
var start = (templateListState.page - 1) * templateListState.size;
|
||||
var slice = items.slice(start, start + templateListState.size);
|
||||
grid.innerHTML = slice.map(function (m, i) { return templateCardHtml(m, i); }).join('');
|
||||
grid.innerHTML = slice.map(function (m, i) { return templateCardHtml(m, start + i); }).join('');
|
||||
bindTemplateCards(grid);
|
||||
renderTemplatePager();
|
||||
}
|
||||
@@ -524,8 +592,12 @@ layui.use(['layer', 'form', 'jquery'], function () {
|
||||
}
|
||||
|
||||
var items = (j.data && j.data.items) ? j.data.items : [];
|
||||
templateListState.items = items;
|
||||
templateListState.total = items.length;
|
||||
var resolved = [];
|
||||
for (var ti = 0; ti < items.length; ti++) {
|
||||
resolved.push(await resolveTemplateItem(items[ti]));
|
||||
}
|
||||
templateListState.items = resolved;
|
||||
templateListState.total = resolved.length;
|
||||
templateListState.page = 1;
|
||||
renderTemplatesPage();
|
||||
|
||||
|
||||
@@ -263,7 +263,8 @@
|
||||
if (act === 'pay') {
|
||||
openPayModal(null, btn.dataset.plan, btn.dataset.price, {
|
||||
orderNo: no,
|
||||
channel: btn.dataset.channel || 'wechat',
|
||||
channel: btn.dataset.channel || (window.SoonMemberPay && SoonMemberPay.defaultChannel
|
||||
? SoonMemberPay.defaultChannel() : 'alipay'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -403,7 +404,7 @@
|
||||
planName: planName,
|
||||
priceDisplay: priceDisplay,
|
||||
orderNo: opts.orderNo,
|
||||
channel: opts.channel || 'wechat',
|
||||
channel: opts.channel || (payCore.defaultChannel ? payCore.defaultChannel() : 'alipay'),
|
||||
onPaid: function () {
|
||||
showPayBanner();
|
||||
loadMembership();
|
||||
|
||||
@@ -719,26 +719,117 @@ body.soon-admin-page .soon-admin-date-wrap input[type="date"].soon-input::-webki
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.soon-admin-key-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr auto;
|
||||
gap: var(--soon-space-sm);
|
||||
align-items: start;
|
||||
padding: var(--soon-space-md) 0;
|
||||
border-bottom: 1px solid var(--soon-border);
|
||||
.soon-admin-form--pay { padding: 0; }
|
||||
.soon-admin-form-panel--pay {
|
||||
padding: var(--soon-space-md) var(--soon-space-lg);
|
||||
}
|
||||
.soon-admin-key-form-row:last-child { border-bottom: none; }
|
||||
.soon-admin-key-form-row__label {
|
||||
font-size: var(--soon-font-sm);
|
||||
color: var(--soon-text);
|
||||
padding-top: 8px;
|
||||
.soon-admin-form--pay .soon-admin-tabs {
|
||||
margin-top: var(--soon-space-md);
|
||||
padding-top: var(--soon-space-md);
|
||||
border-top: 1px solid var(--soon-border);
|
||||
}
|
||||
.soon-admin-key-form-row__status {
|
||||
.soon-admin-form--pay .soon-admin-form__actions {
|
||||
margin-top: var(--soon-space-sm);
|
||||
padding: var(--soon-space-sm) 0 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.soon-admin-pay-status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--soon-space-md);
|
||||
}
|
||||
.soon-admin-pay-status__item {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--soon-font-xs);
|
||||
margin-top: 4px;
|
||||
border: 1px solid var(--soon-border);
|
||||
color: var(--soon-text-muted);
|
||||
}
|
||||
.soon-admin-pay-status__item.is-ok {
|
||||
color: var(--soon-accent);
|
||||
border-color: rgba(0, 150, 136, 0.35);
|
||||
background: rgba(0, 150, 136, 0.08);
|
||||
}
|
||||
.soon-admin-pay-status__item.is-miss {
|
||||
color: var(--soon-danger);
|
||||
border-color: rgba(244, 67, 54, 0.35);
|
||||
background: rgba(244, 67, 54, 0.08);
|
||||
}
|
||||
|
||||
.soon-admin-pay-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 20px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.soon-admin-pay-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.soon-admin-pay-field { min-width: 0; }
|
||||
.soon-admin-pay-field--wide { grid-column: 1 / -1; }
|
||||
.soon-admin-pay-field__label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: var(--soon-font-xs);
|
||||
color: var(--soon-text-muted);
|
||||
}
|
||||
.soon-admin-pay-field__control { min-width: 0; }
|
||||
.soon-admin-pay-field__control > .soon-input { width: 100%; }
|
||||
|
||||
.soon-admin-pay-checks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.soon-input--readonly {
|
||||
opacity: 0.8;
|
||||
cursor: default;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
font-size: var(--soon-font-xs);
|
||||
}
|
||||
|
||||
.soon-admin-pay-key {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.soon-admin-pay-key__badge {
|
||||
min-width: 48px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.soon-admin-pay-key__badge.is-ok {
|
||||
color: var(--soon-accent);
|
||||
background: rgba(0, 150, 136, 0.12);
|
||||
}
|
||||
.soon-admin-pay-key__badge.is-miss {
|
||||
color: var(--soon-danger);
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
.soon-admin-pay-key__text { flex: 1; min-width: 0; }
|
||||
.soon-admin-pay-key__file { cursor: pointer; margin: 0; }
|
||||
.soon-admin-pay-key__paste {
|
||||
display: none;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.soon-admin-pay-key__paste.is-open { display: block; }
|
||||
.soon-admin-pay-key__paste .soon-input { width: 100%; margin-bottom: 4px; }
|
||||
|
||||
.soon-admin-form-checks--inline {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
.soon-admin-key-form-row__status--ok { color: var(--soon-accent); }
|
||||
.soon-admin-key-form-row__status--miss { color: var(--soon-danger); }
|
||||
|
||||
.soon-admin-btn-group {
|
||||
display: flex;
|
||||
|
||||
@@ -1,290 +1,272 @@
|
||||
(function () {
|
||||
|
||||
'use strict';
|
||||
|
||||
|
||||
|
||||
var Api = function () { return window.AdminApi; };
|
||||
|
||||
var Ui = function () { return window.AdminUi; };
|
||||
|
||||
var I = function () { return window.AdminI18n; };
|
||||
|
||||
var Form = function () { return window.AdminForm; };
|
||||
|
||||
var Tabs = function () { return window.AdminTabs; };
|
||||
|
||||
var PARAM_KEYS = [
|
||||
'site.base_url',
|
||||
'site.front_base_url',
|
||||
'alipay.app_id',
|
||||
'wechat.app_id',
|
||||
'wechat.mch_id',
|
||||
'wechat.mch_serial_no',
|
||||
];
|
||||
|
||||
|
||||
var CHANNEL_CONFIG = {
|
||||
|
||||
alipay: {
|
||||
|
||||
label: '支付宝',
|
||||
|
||||
kinds: [
|
||||
|
||||
{ kind: 'private_key', file: 'alipay_private_key.pem', label: '应用私钥 private_key' },
|
||||
|
||||
{ kind: 'public_key', file: 'alipay_public_key.pem', label: '支付宝公钥 public_key' },
|
||||
|
||||
var KEY_FIELDS = {
|
||||
alipay: [
|
||||
{ kind: 'private_key', file: 'alipay_private_key.pem', label: '应用私钥' },
|
||||
{ kind: 'public_key', file: 'alipay_public_key.pem', label: '支付宝公钥' },
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
wechat: {
|
||||
|
||||
label: '微信支付',
|
||||
|
||||
kinds: [
|
||||
|
||||
{ kind: 'mch_private_key', file: 'wechat_mch_private_key.pem', label: '商户私钥 mch_private_key' },
|
||||
|
||||
{ kind: 'api_v3_key', file: 'wechat_api_v3_key.pem', label: 'APIv3 密钥 api_v3_key' },
|
||||
|
||||
{ kind: 'platform_cert', file: 'wechat_platform_cert.pem', label: '平台证书 platform_cert' },
|
||||
|
||||
wechat: [
|
||||
{ kind: 'mch_private_key', file: 'wechat_mch_private_key.pem', label: '商户私钥' },
|
||||
{ kind: 'api_v3_key', file: 'wechat_api_v3_key.pem', label: 'APIv3 密钥', text: true },
|
||||
{ kind: 'platform_cert', file: 'wechat_platform_cert.pem', label: '平台证书' },
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
|
||||
function cfgId(key) {
|
||||
return 'pay_' + key.replace(/\./g, '_');
|
||||
}
|
||||
|
||||
function fileSet(items) {
|
||||
|
||||
var set = {};
|
||||
|
||||
(items || []).forEach(function (p) { set[p.name] = p; });
|
||||
|
||||
return set;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function channelFormHtml(channel, files) {
|
||||
|
||||
var cfg = CHANNEL_CONFIG[channel];
|
||||
|
||||
var rows = cfg.kinds.map(function (k) {
|
||||
|
||||
var ok = !!files[k.file];
|
||||
|
||||
var statusCls = ok ? 'soon-admin-key-form-row__status--ok' : 'soon-admin-key-form-row__status--miss';
|
||||
|
||||
var statusText = ok ? '已就绪' : '缺失';
|
||||
|
||||
var id = channel + '_' + k.kind;
|
||||
|
||||
return '<div class="soon-admin-key-form-row">' +
|
||||
|
||||
'<div><div class="soon-admin-key-form-row__label">' + I().esc(k.label) + '</div>' +
|
||||
|
||||
'<div class="soon-admin-key-form-row__status ' + statusCls + '">' + statusText + '</div></div>' +
|
||||
|
||||
'<textarea id="' + id + '" class="soon-input soon-input--sm soon-input--textarea" rows="4" ' +
|
||||
|
||||
'placeholder="粘贴 PEM 内容或拖入 .pem 文件"></textarea>' +
|
||||
|
||||
'<button type="button" class="soon-btn soon-btn--sm pay-upload-btn" data-channel="' + channel +
|
||||
|
||||
'" data-kind="' + k.kind + '" data-target="' + id + '">上传</button></div>';
|
||||
|
||||
}).join('');
|
||||
|
||||
return '<div class="soon-admin-form">' + Form().panel(cfg.label + ' 密钥', rows) +
|
||||
|
||||
'<details class="soon-admin-hint"><summary>回调说明</summary>' +
|
||||
|
||||
'<p>notify:<code>/api/v1/pay/' + channel + '/notify</code>(须配置 site.base_url 为 HTTPS 外网地址)</p></details>' +
|
||||
|
||||
'<div class="soon-admin-upload" id="payDrop_' + channel + '">拖拽 .pem 到此处自动填入对应字段</div></div>';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function filesTabHtml(items) {
|
||||
|
||||
var rows = (items || []).map(function (p) {
|
||||
|
||||
return '<tr><td>' + I().esc(p.name) + '</td><td>' + p.bytes + ' B</td><td>' +
|
||||
|
||||
I().formatDateTime(p.mtime) + '</td></tr>';
|
||||
|
||||
}).join('');
|
||||
|
||||
return Ui().dataTable(['文件名', '大小', '修改时间'], rows);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function uploadPem(channel, kind, content, onDone) {
|
||||
|
||||
return Api().fetch('payment/upload', {
|
||||
|
||||
method: 'POST',
|
||||
|
||||
body: JSON.stringify({ channel: channel, kind: kind, content: content }),
|
||||
|
||||
}).then(function (j) {
|
||||
|
||||
if (j) {
|
||||
|
||||
soonToast('密钥已上传', 'success');
|
||||
|
||||
if (onDone) onDone();
|
||||
|
||||
}
|
||||
|
||||
return j;
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function bindChannel(channel, panel) {
|
||||
|
||||
panel.querySelectorAll('.pay-upload-btn').forEach(function (btn) {
|
||||
|
||||
btn.onclick = function () {
|
||||
|
||||
var target = Ui().el(btn.dataset.target);
|
||||
|
||||
var content = target ? target.value.trim() : '';
|
||||
|
||||
if (!content) {
|
||||
|
||||
soonToast('请先粘贴或拖入 PEM 内容', 'warn');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
Ui().runFormSubmit(btn, function () {
|
||||
return uploadPem(btn.dataset.channel, btn.dataset.kind, content, render);
|
||||
});
|
||||
|
||||
function deployDefaults() {
|
||||
var cfg = window.SOON_DEPLOY_CONFIG || {};
|
||||
return {
|
||||
apiBase: String(cfg.api_v1_base || '').replace(/\/api\/v1\/?$/, ''),
|
||||
frontBase: cfg.front_base_url || cfg.web_base || '',
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
var drop = Ui().el('payDrop_' + channel);
|
||||
|
||||
if (!drop) return;
|
||||
|
||||
drop.ondragover = function (e) { e.preventDefault(); drop.classList.add('is-dragover'); };
|
||||
|
||||
drop.ondragleave = function () { drop.classList.remove('is-dragover'); };
|
||||
|
||||
drop.ondrop = function (e) {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
drop.classList.remove('is-dragover');
|
||||
|
||||
var file = e.dataTransfer.files[0];
|
||||
|
||||
if (!file) return;
|
||||
|
||||
file.text().then(function (text) {
|
||||
|
||||
var name = file.name.toLowerCase();
|
||||
|
||||
var cfg = CHANNEL_CONFIG[channel];
|
||||
|
||||
var matched = cfg.kinds.find(function (k) { return name.indexOf(k.kind.replace(/_/g, '')) >= 0 || name === k.file; });
|
||||
|
||||
var targetId = matched ? channel + '_' + matched.kind : channel + '_' + cfg.kinds[0].kind;
|
||||
|
||||
var ta = Ui().el(targetId);
|
||||
|
||||
if (ta) ta.value = text;
|
||||
|
||||
soonToast('已填入 ' + (matched ? matched.label : '首个字段') + ',请确认后点击上传', 'success');
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
function parseDisplayChannels(values) {
|
||||
var raw = values && values['payment.display_channels'] != null
|
||||
? values['payment.display_channels'] : 'alipay';
|
||||
return String(raw).split(/[\s,]+/).filter(function (v) { return v === 'alipay' || v === 'wechat'; });
|
||||
}
|
||||
|
||||
function statusLine(readiness) {
|
||||
readiness = readiness || {};
|
||||
var a = readiness.alipay || {};
|
||||
var w = readiness.wechat || {};
|
||||
return '<div class="soon-admin-pay-status">' +
|
||||
'<span class="soon-admin-pay-status__item' + (a.ready ? ' is-ok' : ' is-miss') + '">支付宝</span>' +
|
||||
'<span class="soon-admin-pay-status__item' + (w.ready ? ' is-ok' : ' is-miss') + '">微信支付</span></div>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
function field(label, control, wide) {
|
||||
return '<div class="soon-admin-pay-field' + (wide ? ' soon-admin-pay-field--wide' : '') + '">' +
|
||||
'<label class="soon-admin-pay-field__label">' + I().esc(label) + '</label>' +
|
||||
'<div class="soon-admin-pay-field__control">' + control + '</div></div>';
|
||||
}
|
||||
|
||||
var panel = Ui().el('panel-payment');
|
||||
function grid(cells) {
|
||||
return '<div class="soon-admin-pay-grid">' + cells.join('') + '</div>';
|
||||
}
|
||||
|
||||
if (!panel) return;
|
||||
function textInput(key, values, placeholder) {
|
||||
var val = values && values[key] != null ? values[key] : '';
|
||||
return Form().input(cfgId(key), { value: val, placeholder: placeholder || '' });
|
||||
}
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + Ui().pageHeader('支付配置', '按渠道管理密钥文件,上传后立即热加载') +
|
||||
function readonlyInput(value) {
|
||||
return '<input class="soon-input soon-input--sm soon-input--readonly" readonly value="' +
|
||||
I().esc(value || '') + '">';
|
||||
}
|
||||
|
||||
Ui().skeletonTable() + '</div>';
|
||||
function displayChannelsField(selected) {
|
||||
if (!selected.length) selected = ['alipay'];
|
||||
var mk = function (ch, label) {
|
||||
var on = selected.indexOf(ch) >= 0;
|
||||
return '<label class="soon-admin-check"><input type="checkbox" class="pay-disp-ch" value="' + ch + '"' +
|
||||
(on ? ' checked' : '') + '> ' + label + '</label>';
|
||||
};
|
||||
return field('用户端展示',
|
||||
'<div class="soon-admin-pay-checks">' + mk('alipay', '支付宝') + mk('wechat', '微信') + '</div>', true);
|
||||
}
|
||||
|
||||
function siteGrid(values, notifyUrls) {
|
||||
var defs = deployDefaults();
|
||||
return grid([
|
||||
displayChannelsField(parseDisplayChannels(values)),
|
||||
field('API 外网', textInput('site.base_url', values, defs.apiBase)),
|
||||
field('前端站点', textInput('site.front_base_url', values, defs.frontBase)),
|
||||
field('支付宝回调', readonlyInput(notifyUrls.alipay)),
|
||||
field('微信回调', readonlyInput(notifyUrls.wechat)),
|
||||
]);
|
||||
}
|
||||
|
||||
function keyField(channel, keyDef, files) {
|
||||
var ok = !!files[keyDef.file];
|
||||
var id = channel + '_' + keyDef.kind;
|
||||
var pasteId = id + '_paste';
|
||||
var badge = '<span class="soon-admin-pay-key__badge ' + (ok ? 'is-ok' : 'is-miss') + '">' +
|
||||
(ok ? '已配置' : '未配置') + '</span>';
|
||||
var inner;
|
||||
if (keyDef.text) {
|
||||
inner = badge +
|
||||
Form().input(id + '_text', { placeholder: 'APIv3 密钥', className: 'soon-admin-pay-key__text' }) +
|
||||
'<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost pay-key-upload" data-channel="' +
|
||||
channel + '" data-kind="' + keyDef.kind + '" data-source="' + id + '_text">写入</button>';
|
||||
} else {
|
||||
inner = badge +
|
||||
'<label class="soon-btn soon-btn--xs soon-btn--ghost soon-admin-pay-key__file">上传' +
|
||||
'<input type="file" class="pay-key-file" accept=".pem,.txt,.key" data-channel="' +
|
||||
channel + '" data-kind="' + keyDef.kind + '" hidden></label>' +
|
||||
'<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost pay-key-paste" data-target="' +
|
||||
pasteId + '">粘贴</button>' +
|
||||
'<div id="' + pasteId + '_box" class="soon-admin-pay-key__paste">' +
|
||||
'<textarea id="' + pasteId + '" class="soon-input soon-input--sm" rows="2" placeholder="PEM"></textarea>' +
|
||||
'<button type="button" class="soon-btn soon-btn--xs pay-key-commit" data-channel="' + channel +
|
||||
'" data-kind="' + keyDef.kind + '" data-target="' + pasteId + '">确认</button></div>';
|
||||
}
|
||||
return field(keyDef.label, '<div class="soon-admin-pay-key">' + inner + '</div>');
|
||||
}
|
||||
|
||||
Api().get('payment/status').then(function (j) {
|
||||
|
||||
if (!j) return;
|
||||
|
||||
var items = j.data.items || [];
|
||||
|
||||
var files = fileSet(items);
|
||||
|
||||
|
||||
|
||||
var tabsHtml = Tabs().html('paymentTabs', [
|
||||
function channelGrid(channel, files, values) {
|
||||
var cells = [];
|
||||
if (channel === 'alipay') {
|
||||
cells.push(field('APPID', textInput('alipay.app_id', values, '开放平台 APPID')));
|
||||
KEY_FIELDS.alipay.forEach(function (f) { cells.push(keyField('alipay', f, files)); });
|
||||
} else {
|
||||
cells.push(field('AppID', textInput('wechat.app_id', values, 'AppID')));
|
||||
cells.push(field('商户号', textInput('wechat.mch_id', values, 'mch_id')));
|
||||
cells.push(field('证书序列号', textInput('wechat.mch_serial_no', values, 'serial_no')));
|
||||
KEY_FIELDS.wechat.forEach(function (f) { cells.push(keyField('wechat', f, files)); });
|
||||
}
|
||||
return grid(cells);
|
||||
}
|
||||
|
||||
function formHtml(values, files, notifyUrls, readiness) {
|
||||
var tabs = Tabs().html('paymentTabs', [
|
||||
{ id: 'alipay', label: '支付宝' },
|
||||
|
||||
{ id: 'wechat', label: '微信支付' },
|
||||
|
||||
{ id: 'files', label: '文件清单' },
|
||||
|
||||
{ id: 'wechat', label: '微信' },
|
||||
], {
|
||||
|
||||
alipay: channelFormHtml('alipay', files),
|
||||
|
||||
wechat: channelFormHtml('wechat', files),
|
||||
|
||||
files: filesTabHtml(items),
|
||||
|
||||
alipay: channelGrid('alipay', files, values),
|
||||
wechat: channelGrid('wechat', files, values),
|
||||
}, 'alipay');
|
||||
|
||||
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
|
||||
Ui().pageHeader('支付配置', '按渠道管理密钥文件,上传后立即热加载') +
|
||||
|
||||
tabsHtml + '</div>';
|
||||
|
||||
|
||||
|
||||
var tabsRoot = Ui().el('paymentTabs');
|
||||
|
||||
Tabs().bind(tabsRoot);
|
||||
|
||||
bindChannel('alipay', panel);
|
||||
|
||||
bindChannel('wechat', panel);
|
||||
|
||||
});
|
||||
|
||||
return '<div class="soon-admin-form soon-admin-form--pay">' +
|
||||
'<div class="soon-admin-form-panel soon-admin-form-panel--pay">' +
|
||||
statusLine(readiness) + siteGrid(values, notifyUrls) + tabs +
|
||||
'</div>' + Form().actions('保存', 'payFormSave') + '</div>';
|
||||
}
|
||||
|
||||
function collectParams() {
|
||||
var items = PARAM_KEYS.map(function (key) {
|
||||
var el = Ui().el(cfgId(key));
|
||||
return { key: key, value: el ? el.value.trim() : '' };
|
||||
});
|
||||
var disp = [];
|
||||
document.querySelectorAll('.pay-disp-ch:checked').forEach(function (el) { disp.push(el.value); });
|
||||
items.push({ key: 'payment.display_channels', value: disp.join(',') });
|
||||
return items;
|
||||
}
|
||||
|
||||
function uploadKey(channel, kind, content) {
|
||||
return Api().fetch('payment/upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel: channel, kind: kind, content: content }),
|
||||
});
|
||||
}
|
||||
|
||||
function bindKeys(panel) {
|
||||
panel.querySelectorAll('.pay-key-file').forEach(function (input) {
|
||||
input.onchange = function () {
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
file.text().then(function (text) {
|
||||
var content = text.trim();
|
||||
if (!content) {
|
||||
soonToast('文件为空', 'warn');
|
||||
return;
|
||||
}
|
||||
uploadKey(input.dataset.channel, input.dataset.kind, content).then(function (j) {
|
||||
if (j) {
|
||||
soonToast('已保存', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
}).finally(function () { input.value = ''; });
|
||||
};
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.pay-key-paste').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
var box = Ui().el(btn.dataset.target + '_box');
|
||||
if (!box) return;
|
||||
var open = box.classList.toggle('is-open');
|
||||
var wrap = box.closest('.soon-admin-pay-field');
|
||||
if (wrap) wrap.classList.toggle('soon-admin-pay-field--wide', open);
|
||||
if (open) {
|
||||
var ta = Ui().el(btn.dataset.target);
|
||||
if (ta) ta.focus();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.pay-key-upload, .pay-key-commit').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
var content = '';
|
||||
if (btn.classList.contains('pay-key-upload')) {
|
||||
var src = Ui().el(btn.dataset.source);
|
||||
content = src ? src.value.trim() : '';
|
||||
} else {
|
||||
var ta = Ui().el(btn.dataset.target);
|
||||
content = ta ? ta.value.trim() : '';
|
||||
}
|
||||
if (!content) {
|
||||
soonToast('内容为空', 'warn');
|
||||
return;
|
||||
}
|
||||
Ui().runFormSubmit(btn, function () {
|
||||
return uploadKey(btn.dataset.channel, btn.dataset.kind, content).then(function (j) {
|
||||
if (j) {
|
||||
soonToast('已保存', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function bindSave() {
|
||||
Ui().bindFormSubmit('payFormSave', function () {
|
||||
return Api().postJson('payment/config', { items: collectParams() }).then(function (j) {
|
||||
if (j) {
|
||||
soonToast('已保存', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
var panel = Ui().el('panel-payment');
|
||||
if (!panel) return;
|
||||
var header = Ui().pageHeader('支付配置');
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + header + Ui().skeletonTable() + '</div>';
|
||||
|
||||
Api().get('payment/status').then(function (j) {
|
||||
if (!j) return;
|
||||
var data = j.data || {};
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + header +
|
||||
formHtml(data.params || {}, fileSet(data.items), data.notify_urls || {}, data.readiness) +
|
||||
'</div>';
|
||||
Tabs().bind(Ui().el('paymentTabs'));
|
||||
bindSave();
|
||||
bindKeys(panel);
|
||||
});
|
||||
}
|
||||
|
||||
window.AdminViews = window.AdminViews || {};
|
||||
|
||||
window.AdminViews.payment = { render: render };
|
||||
|
||||
})();
|
||||
|
||||
|
||||
@@ -5,39 +5,22 @@
|
||||
var Ui = function () { return window.AdminUi; };
|
||||
var I = function () { return window.AdminI18n; };
|
||||
var Form = function () { return window.AdminForm; };
|
||||
var Tabs = function () { return window.AdminTabs; };
|
||||
var Filter = function () { return window.AdminFilter; };
|
||||
var State = function () { return window.AdminState; };
|
||||
var Router = function () { return window.AdminRouter; };
|
||||
|
||||
var SETTING_LABELS = {
|
||||
'site.base_url': 'API 站点外网地址',
|
||||
'site.front_base_url': '前端站点地址',
|
||||
'site.base_url': 'API 站点外网地址(见支付配置)',
|
||||
'payment.display_channels': '前端展示渠道(见支付配置)',
|
||||
'alipay.app_id': '支付宝 APPID(见支付配置)',
|
||||
'wechat.app_id': '微信 AppID(见支付配置)',
|
||||
'wechat.mch_id': '微信商户号(见支付配置)',
|
||||
'wechat.mch_serial_no': '商户证书序列号(见支付配置)',
|
||||
};
|
||||
|
||||
var valuesByKey = {};
|
||||
var allItems = [];
|
||||
|
||||
function siteFields() {
|
||||
var cfg = window.SOON_DEPLOY_CONFIG || {};
|
||||
var apiBase = String(cfg.api_v1_base || '').replace(/\/api\/v1\/?$/, '');
|
||||
var frontBase = cfg.front_base_url || cfg.web_base || '';
|
||||
return [
|
||||
{
|
||||
key: 'site.base_url',
|
||||
label: 'API 站点外网地址',
|
||||
hint: '须 HTTPS 外网可达;支付宝/微信 notify 回调依赖此地址',
|
||||
placeholder: apiBase,
|
||||
},
|
||||
{
|
||||
key: 'site.front_base_url',
|
||||
label: '前端站点地址',
|
||||
hint: '支付完成 return_url 跳转至会员页',
|
||||
placeholder: frontBase,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function settingLabel(key) {
|
||||
if (SETTING_LABELS[key]) {
|
||||
return I().esc(key) + '<br><span class="soon-admin-setting-hint">' + I().esc(SETTING_LABELS[key]) + '</span>';
|
||||
@@ -50,17 +33,6 @@
|
||||
return I().esc(val);
|
||||
}
|
||||
|
||||
function siteFormHtml() {
|
||||
var fields = siteFields().map(function (f) {
|
||||
var val = valuesByKey[f.key] != null ? valuesByKey[f.key] : '';
|
||||
return Form().field(f.label,
|
||||
Form().input('cfg_' + f.key.replace(/\./g, '_'), { value: val, placeholder: f.placeholder }),
|
||||
f.hint);
|
||||
}).join('');
|
||||
return '<div class="soon-admin-form">' + Form().panel('站点与回调地址', fields) +
|
||||
Form().actions('保存站点配置', 'settingsSiteSave') + '</div>';
|
||||
}
|
||||
|
||||
function allSettingsHtml(filterQ) {
|
||||
var q = (filterQ || '').toLowerCase();
|
||||
var items = allItems.filter(function (s) {
|
||||
@@ -82,21 +54,6 @@
|
||||
return filter + Ui().dataTable(['配置项', '当前值', '更新时间', '操作'], rows);
|
||||
}
|
||||
|
||||
function bindSiteForm() {
|
||||
Ui().bindFormSubmit('settingsSiteSave', function () {
|
||||
var items = siteFields().map(function (f) {
|
||||
var el = Ui().el('cfg_' + f.key.replace(/\./g, '_'));
|
||||
return { key: f.key, value: el ? el.value.trim() : '' };
|
||||
});
|
||||
return Api().postJson('settings/batch', { items: items }).then(function (r) {
|
||||
if (r) {
|
||||
soonToast('站点配置已保存', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindAllSettings() {
|
||||
var st = State().settings;
|
||||
var searchBtn = Ui().el('settingsSearchBtn');
|
||||
@@ -154,7 +111,7 @@
|
||||
var panel = Ui().el('panel-settings');
|
||||
if (!panel) return;
|
||||
var st = State().settings;
|
||||
var header = Ui().pageHeader('系统设置', '站点地址与运行时参数;修改后即时生效');
|
||||
var header = Ui().pageHeader('系统设置', '运行时参数;支付相关请在「支付配置」维护');
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + header + Ui().skeletonTable() + '</div>';
|
||||
|
||||
Api().get('settings').then(function (j) {
|
||||
@@ -168,20 +125,7 @@
|
||||
allItems = j.data.items || [];
|
||||
allItems.forEach(function (s) { valuesByKey[s.key] = s.value; });
|
||||
|
||||
var tabsHtml = Tabs().html('settingsTabs', [
|
||||
{ id: 'site', label: '站点配置' },
|
||||
{ id: 'all', label: '全部配置' },
|
||||
], {
|
||||
site: siteFormHtml(),
|
||||
all: allSettingsHtml(st.q),
|
||||
}, st.tab);
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + header + tabsHtml + '</div>';
|
||||
Tabs().bind(Ui().el('settingsTabs'), function (tabId) {
|
||||
st.tab = tabId;
|
||||
Router().syncQuery();
|
||||
});
|
||||
bindSiteForm();
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + header + allSettingsHtml(st.q) + '</div>';
|
||||
bindAllSettings();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../../assets/deploy-config.js"></script>
|
||||
<script src="../../config/local.js"></script>
|
||||
<script src="../../js/common/auth-redirect.js"></script>
|
||||
<script src="../../vendor/layui/layui.js"></script>
|
||||
<script src="../../js/common/soon-toast.js"></script>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../../assets/deploy-config.js"></script>
|
||||
<script src="../../config/local.js"></script>
|
||||
<script src="../../js/common/auth-redirect.js"></script>
|
||||
<script src="../../vendor/layui/layui.js"></script>
|
||||
<script src="../../js/common/soon-toast.js"></script>
|
||||
|
||||
@@ -875,7 +875,7 @@
|
||||
<script src="../vendor/js/print.min.js"></script>
|
||||
<script src="../vendor/js/crypto-js.min.js"></script>
|
||||
<script src="../vendor/js/jspdf.umd.min.js"></script>
|
||||
<script src="../assets/deploy-config.js"></script>
|
||||
<script src="../config/local.js"></script>
|
||||
<script src="../js/common/auth-redirect.js"></script>
|
||||
<script src="../js/common/auth-session.js"></script>
|
||||
<script src="../js/common/member-pay-core.js"></script>
|
||||
|
||||
@@ -665,7 +665,7 @@
|
||||
<script src="../vendor/js/print.min.js"></script>
|
||||
<script src="../vendor/js/crypto-js.min.js"></script>
|
||||
<script src="../vendor/js/jspdf.umd.min.js"></script>
|
||||
<script src="../assets/deploy-config.js"></script>
|
||||
<script src="../config/local.js"></script>
|
||||
<script src="../js/common/auth-redirect.js"></script>
|
||||
<script src="../js/common/auth-session.js"></script>
|
||||
<script src="../js/common/member-pay-core.js"></script>
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
|
||||
|
||||
|
||||
<script src="../assets/deploy-config.js"></script>
|
||||
<script src="../config/local.js"></script>
|
||||
|
||||
<script src="../js/common/auth-redirect.js"></script>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../assets/deploy-config.js"></script>
|
||||
<script src="../config/local.js"></script>
|
||||
<script src="../js/common/portal-topbar.js"></script>
|
||||
<script>SoonPortalTopbar.mount('#portal-topbar', { variant: 'auth' });</script>
|
||||
<script src="../js/common/auth-redirect.js"></script>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="../assets/deploy-config.js"></script>
|
||||
<script src="../config/local.js"></script>
|
||||
<script src="../js/common/auth-redirect.js"></script>
|
||||
<script src="../js/common/auth-session.js"></script>
|
||||
<script src="../js/common/member-pay-core.js"></script>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../assets/deploy-config.js"></script>
|
||||
<script src="../config/local.js"></script>
|
||||
<script src="../js/common/portal-topbar.js"></script>
|
||||
<script>SoonPortalTopbar.mount('#portal-topbar', { variant: 'auth' });</script>
|
||||
<script src="../js/common/auth-redirect.js"></script>
|
||||
|
||||
+1
-5
@@ -57,11 +57,7 @@ if errorlevel 1 (
|
||||
goto :fail
|
||||
)
|
||||
|
||||
if not exist "soonModels\manifest.json" (
|
||||
echo [ERROR] Missing soonModels\manifest.json
|
||||
echo Project root: %CD%
|
||||
goto :fail
|
||||
)
|
||||
if not exist "soonModels\" mkdir soonModels
|
||||
|
||||
if not exist "docker\docker-compose.yml" (
|
||||
echo [ERROR] Missing docker\docker-compose.yml
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"items": [],
|
||||
"count": 0
|
||||
}
|
||||
Reference in New Issue
Block a user