diff --git a/.cursor/rules/agent-core.mdc b/.cursor/rules/agent-core.mdc index 07790ce..f36a842 100644 --- a/.cursor/rules/agent-core.mdc +++ b/.cursor/rules/agent-core.mdc @@ -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`)。 ## 改动 diff --git a/.gitignore b/.gitignore index b75416d..3d0aaac 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 1b6ed80..3fa81bb 100644 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/backend-web/config/local.php.example b/backend-web/config/local.php.example index c02590c..d5e7618 100644 --- a/backend-web/config/local.php.example +++ b/backend-web/config/local.php.example @@ -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', + ], ]; diff --git a/backend-web/public/admin.php b/backend-web/public/admin.php index b2dc5dd..0f5f43b 100644 --- a/backend-web/public/admin.php +++ b/backend-web/public/admin.php @@ -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/')) { diff --git a/backend-web/public/index.php b/backend-web/public/index.php index 019e8cb..15742e6 100644 --- a/backend-web/public/index.php +++ b/backend-web/public/index.php @@ -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']); diff --git a/backend-web/scripts/README.md b/backend-web/scripts/README.md new file mode 100644 index 0000000..87964c3 --- /dev/null +++ b/backend-web/scripts/README.md @@ -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 粘贴块。 diff --git a/backend-web/scripts/check-soon-models.php b/backend-web/scripts/check-soon-models.php new file mode 100644 index 0000000..b329122 --- /dev/null +++ b/backend-web/scripts/check-soon-models.php @@ -0,0 +1,45 @@ + 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); diff --git a/backend-web/scripts/seed-admin.php b/backend-web/scripts/seed-admin.php new file mode 100644 index 0000000..3868e01 --- /dev/null +++ b/backend-web/scripts/seed-admin.php @@ -0,0 +1,59 @@ + 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"); diff --git a/backend-web/scripts/setup-backend-perms.sh b/backend-web/scripts/setup-backend-perms.sh new file mode 100644 index 0000000..404ace4 --- /dev/null +++ b/backend-web/scripts/setup-backend-perms.sh @@ -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 diff --git a/backend-web/src/Admin/Controllers/PaymentController.php b/backend-web/src/Admin/Controllers/PaymentController.php index b26b205..e219164 100644 --- a/backend-web/src/Admin/Controllers/PaymentController.php +++ b/backend-web/src/Admin/Controllers/PaymentController.php @@ -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)); + } } diff --git a/backend-web/src/Admin/Controllers/SettingsController.php b/backend-web/src/Admin/Controllers/SettingsController.php index f5d4021..f35fc76 100644 --- a/backend-web/src/Admin/Controllers/SettingsController.php +++ b/backend-web/src/Admin/Controllers/SettingsController.php @@ -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]); } } diff --git a/backend-web/src/Admin/Services/PaymentKeyStore.php b/backend-web/src/Admin/Services/PaymentKeyStore.php index 91b80e2..b6e961b 100644 --- a/backend-web/src/Admin/Services/PaymentKeyStore.php +++ b/backend-web/src/Admin/Services/PaymentKeyStore.php @@ -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() + ); } } diff --git a/backend-web/src/Controllers/PayController.php b/backend-web/src/Controllers/PayController.php index be795d5..13e2537 100644 --- a/backend-web/src/Controllers/PayController.php +++ b/backend-web/src/Controllers/PayController.php @@ -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)); } diff --git a/backend-web/src/Controllers/SettingsController.php b/backend-web/src/Controllers/SettingsController.php index 3f41e02..fe4dc00 100644 --- a/backend-web/src/Controllers/SettingsController.php +++ b/backend-web/src/Controllers/SettingsController.php @@ -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(), + ]); } } diff --git a/backend-web/src/Controllers/SoonModelController.php b/backend-web/src/Controllers/SoonModelController.php index 926d321..51a394e 100644 --- a/backend-web/src/Controllers/SoonModelController.php +++ b/backend-web/src/Controllers/SoonModelController.php @@ -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; } } diff --git a/backend-web/src/Services/PayService.php b/backend-web/src/Services/PayService.php index 824cb77..cf7e165 100644 --- a/backend-web/src/Services/PayService.php +++ b/backend-web/src/Services/PayService.php @@ -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; diff --git a/backend-web/src/Services/PaymentConfig.php b/backend-web/src/Services/PaymentConfig.php new file mode 100644 index 0000000..c5ac6cf --- /dev/null +++ b/backend-web/src/Services/PaymentConfig.php @@ -0,0 +1,255 @@ +> */ + 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 */ + 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 */ + 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 */ + public static function paramValues(): array + { + $out = []; + foreach (SettingsConfigLoader::PAYMENT_KEYS as $key) { + $out[$key] = self::stringValue($key, Config::get($key)); + } + return $out; + } + + /** @param list $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 */ + 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}> */ + 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 */ + private static function parseDisplayChannelsValue(string $value): array + { + return self::parseDisplayChannelsList( + preg_split('/[\s,]+/', trim($value), -1, PREG_SPLIT_NO_EMPTY) ?: [] + ); + } + + /** @param list $parts @return list */ + 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; + } +} diff --git a/backend-web/src/Services/PaymentConfigLoader.php b/backend-web/src/Services/PaymentConfigLoader.php index cbf63a4..d0d12ad 100644 --- a/backend-web/src/Services/PaymentConfigLoader.php +++ b/backend-web/src/Services/PaymentConfigLoader.php @@ -38,4 +38,10 @@ final class PaymentConfigLoader $config[$section][$key] = $content; } } + + public static function reload(array &$config): void + { + self::merge($config); + SettingsConfigLoader::merge($config); + } } diff --git a/backend-web/src/Services/SettingsConfigLoader.php b/backend-web/src/Services/SettingsConfigLoader.php new file mode 100644 index 0000000..a300cff --- /dev/null +++ b/backend-web/src/Services/SettingsConfigLoader.php @@ -0,0 +1,69 @@ + */ + 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]; + } + } +} diff --git a/backend-web/src/Services/SoonModelService.php b/backend-web/src/Services/SoonModelService.php new file mode 100644 index 0000000..e9a83a8 --- /dev/null +++ b/backend-web/src/Services/SoonModelService.php @@ -0,0 +1,179 @@ + */ + 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> */ + 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 */ + 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|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) : ''; + } +} diff --git a/backend-web/src/bootstrap.php b/backend-web/src/bootstrap.php index 20240a7..9781cc5 100644 --- a/backend-web/src/bootstrap.php +++ b/backend-web/src/bootstrap.php @@ -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); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6c1d2dd..7c83417 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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 diff --git a/docker/sync-config.ps1 b/docker/sync-config.ps1 index a1aa0d1..37aca50 100644 --- a/docker/sync-config.ps1 +++ b/docker/sync-config.ps1 @@ -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) diff --git a/docs/API-PAGINATION.md b/docs/API-PAGINATION.md index cb4a8f5..ebd6342 100644 --- a/docs/API-PAGINATION.md +++ b/docs/API-PAGINATION.md @@ -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` 自动生成的列表 | --- diff --git a/docs/PAYMENT.md b/docs/PAYMENT.md index 3ec392e..602693e 100644 --- a/docs/PAYMENT.md +++ b/docs/PAYMENT.md @@ -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 路径 diff --git a/frontend-web/assets/css/member.css b/frontend-web/assets/css/member.css index 778febf..e8ba3a2 100644 --- a/frontend-web/assets/css/member.css +++ b/frontend-web/assets/css/member.css @@ -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; diff --git a/frontend-web/assets/css/portal.css b/frontend-web/assets/css/portal.css index 3d51c69..1757c98 100644 --- a/frontend-web/assets/css/portal.css +++ b/frontend-web/assets/css/portal.css @@ -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; diff --git a/frontend-web/assets/deploy-config.js b/frontend-web/assets/deploy-config.js deleted file mode 100644 index d8cb60b..0000000 --- a/frontend-web/assets/deploy-config.js +++ /dev/null @@ -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' -}; diff --git a/frontend-web/assets/deploy-config.json b/frontend-web/assets/deploy-config.json deleted file mode 100644 index 1834ec3..0000000 --- a/frontend-web/assets/deploy-config.json +++ /dev/null @@ -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" -} diff --git a/frontend-web/config/local.js.example b/frontend-web/config/local.js.example new file mode 100644 index 0000000..3a4b53c --- /dev/null +++ b/frontend-web/config/local.js.example @@ -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' +}; diff --git a/frontend-web/js/common/member-pay-core.js b/frontend-web/js/common/member-pay-core.js index 37a86d0..d58e80f 100644 --- a/frontend-web/js/common/member-pay-core.js +++ b/frontend-web/js/common/member-pay-core.js @@ -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 = '' + + '' + ui.icon + '' + + '' + ui.name + '' + + '' + ui.hint + ''; + if (selectable) { + return ''; + } + return '
' + inner + '
'; + } + + 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 '
' + + '

支付方式

' + + '
' + + cards + '
'; + } + 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 '
' + '' + - '

等待生成付款码

' + - '

确认支付方式后,将在此显示二维码或跳转链接

'; + '

等待生成付款信息

' + + '

点击下方按钮,将使用' + esc(ui.name) + '(' + esc(ui.hint) + ')

'; } 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 ? '' @@ -222,21 +310,9 @@ '
' + orderInBody + payStepperHtml(1, channel) + - '
' + - '

支付方式

' + - '
' + - '' + - '
' + + payChannelsHtml(channel, channels, { selectable: !resumeOrderNo && channels.length > 1 }) + '
' + - '
' + payEmptyStateHtml() + '
' + + '
' + payEmptyStateHtml(channel) + '
' + '
' + '

' + '
'; - - }).join(''); - - return '
' + Form().panel(cfg.label + ' 密钥', rows) + - - '
回调说明' + - - '

notify:/api/v1/pay/' + channel + '/notify(须配置 site.base_url 为 HTTPS 外网地址)

' + - - '
拖拽 .pem 到此处自动填入对应字段
'; - - } - - - - function filesTabHtml(items) { - - var rows = (items || []).map(function (p) { - - return '' + I().esc(p.name) + '' + p.bytes + ' B' + - - I().formatDateTime(p.mtime) + ''; - - }).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); - }); - - }; - - }); - - - - 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 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 || '', }; - } + 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 '
' + + '支付宝' + + '微信支付
'; + } + + function field(label, control, wide) { + return '
' + + '' + + '
' + control + '
'; + } + + function grid(cells) { + return '
' + cells.join('') + '
'; + } + + function textInput(key, values, placeholder) { + var val = values && values[key] != null ? values[key] : ''; + return Form().input(cfgId(key), { value: val, placeholder: placeholder || '' }); + } + + function readonlyInput(value) { + return ''; + } + + function displayChannelsField(selected) { + if (!selected.length) selected = ['alipay']; + var mk = function (ch, label) { + var on = selected.indexOf(ch) >= 0; + return ''; + }; + return field('用户端展示', + '
' + mk('alipay', '支付宝') + mk('wechat', '微信') + '
', 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 = '' + + (ok ? '已配置' : '未配置') + ''; + var inner; + if (keyDef.text) { + inner = badge + + Form().input(id + '_text', { placeholder: 'APIv3 密钥', className: 'soon-admin-pay-key__text' }) + + ''; + } else { + inner = badge + + '' + + '' + + '
' + + '' + + '
'; + } + return field(keyDef.label, '
' + inner + '
'); + } + + 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: '微信' }, + ], { + alipay: channelGrid('alipay', files, values), + wechat: channelGrid('wechat', files, values), + }, 'alipay'); + + return '
' + + '
' + + statusLine(readiness) + siteGrid(values, notifyUrls) + tabs + + '
' + Form().actions('保存', 'payFormSave') + '
'; + } + + 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; - - panel.innerHTML = '
' + Ui().pageHeader('支付配置', '按渠道管理密钥文件,上传后立即热加载') + - - Ui().skeletonTable() + '
'; - - + var header = Ui().pageHeader('支付配置'); + panel.innerHTML = '
' + header + Ui().skeletonTable() + '
'; Api().get('payment/status').then(function (j) { - if (!j) return; - - var items = j.data.items || []; - - var files = fileSet(items); - - - - var tabsHtml = Tabs().html('paymentTabs', [ - - { id: 'alipay', label: '支付宝' }, - - { id: 'wechat', label: '微信支付' }, - - { id: 'files', label: '文件清单' }, - - ], { - - alipay: channelFormHtml('alipay', files), - - wechat: channelFormHtml('wechat', files), - - files: filesTabHtml(items), - - }, 'alipay'); - - - - panel.innerHTML = '
' + - - Ui().pageHeader('支付配置', '按渠道管理密钥文件,上传后立即热加载') + - - tabsHtml + '
'; - - - - var tabsRoot = Ui().el('paymentTabs'); - - Tabs().bind(tabsRoot); - - bindChannel('alipay', panel); - - bindChannel('wechat', panel); - + var data = j.data || {}; + panel.innerHTML = '
' + header + + formHtml(data.params || {}, fileSet(data.items), data.notify_urls || {}, data.readiness) + + '
'; + Tabs().bind(Ui().el('paymentTabs')); + bindSave(); + bindKeys(panel); }); - } - - window.AdminViews = window.AdminViews || {}; - window.AdminViews.payment = { render: render }; - })(); - diff --git a/frontend-web/pages/admin/assets/js/views/settings.js b/frontend-web/pages/admin/assets/js/views/settings.js index 9528635..f1a89c8 100644 --- a/frontend-web/pages/admin/assets/js/views/settings.js +++ b/frontend-web/pages/admin/assets/js/views/settings.js @@ -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) + '
' + I().esc(SETTING_LABELS[key]) + ''; @@ -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 '
' + Form().panel('站点与回调地址', fields) + - Form().actions('保存站点配置', 'settingsSiteSave') + '
'; - } - 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 = '
' + header + Ui().skeletonTable() + '
'; 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 = '
' + header + tabsHtml + '
'; - Tabs().bind(Ui().el('settingsTabs'), function (tabId) { - st.tab = tabId; - Router().syncQuery(); - }); - bindSiteForm(); + panel.innerHTML = '
' + header + allSettingsHtml(st.q) + '
'; bindAllSettings(); }); } diff --git a/frontend-web/pages/admin/index.html b/frontend-web/pages/admin/index.html index b277a88..a5bb3ae 100644 --- a/frontend-web/pages/admin/index.html +++ b/frontend-web/pages/admin/index.html @@ -35,7 +35,7 @@
- + diff --git a/frontend-web/pages/admin/login.html b/frontend-web/pages/admin/login.html index 900bec1..fc082db 100644 --- a/frontend-web/pages/admin/login.html +++ b/frontend-web/pages/admin/login.html @@ -41,7 +41,7 @@ - + diff --git a/frontend-web/pages/design1.web.html b/frontend-web/pages/design1.web.html index 479153e..b15f972 100644 --- a/frontend-web/pages/design1.web.html +++ b/frontend-web/pages/design1.web.html @@ -875,7 +875,7 @@ - + diff --git a/frontend-web/pages/design2.web.html b/frontend-web/pages/design2.web.html index 2dc8c95..2c4c068 100644 --- a/frontend-web/pages/design2.web.html +++ b/frontend-web/pages/design2.web.html @@ -665,7 +665,7 @@ - + diff --git a/frontend-web/pages/index.web.html b/frontend-web/pages/index.web.html index 0987d1e..0b5bb6c 100644 --- a/frontend-web/pages/index.web.html +++ b/frontend-web/pages/index.web.html @@ -136,7 +136,7 @@ - + diff --git a/frontend-web/pages/login.web.html b/frontend-web/pages/login.web.html index 1cf60d8..2e424b8 100644 --- a/frontend-web/pages/login.web.html +++ b/frontend-web/pages/login.web.html @@ -39,7 +39,7 @@ - + diff --git a/frontend-web/pages/member.web.html b/frontend-web/pages/member.web.html index 37ff9b6..fc2685b 100644 --- a/frontend-web/pages/member.web.html +++ b/frontend-web/pages/member.web.html @@ -50,7 +50,7 @@ - + diff --git a/frontend-web/pages/register.web.html b/frontend-web/pages/register.web.html index ba757c1..b637377 100644 --- a/frontend-web/pages/register.web.html +++ b/frontend-web/pages/register.web.html @@ -39,7 +39,7 @@ - + diff --git a/scripts/start.bat b/scripts/start.bat index d9ca5f4..0420319 100644 --- a/scripts/start.bat +++ b/scripts/start.bat @@ -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 diff --git a/soonModels/manifest.json b/soonModels/manifest.json deleted file mode 100644 index bedaf57..0000000 --- a/soonModels/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "items": [], - "count": 0 -}