前端配置迁至 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
@@ -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) : '';
}
}