前端配置迁至 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:
24kycj
2026-06-08 20:48:51 +08:00
parent 88c6ce8ccc
commit 152228d41f
47 changed files with 1448 additions and 486 deletions
+1
View File
@@ -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;
+255
View File
@@ -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) : '';
}
}