152228d41f
- 页面直接引用 local.js 设置 SOON_DEPLOY_CONFIG,移除 deploy-config - Docker sync-config 生成 local.js;更新 README 与 agent-core 说明 - 模板库自动扫描 .soon;新增后端部署/种子/排查脚本 - 完善支付配置、订阅弹窗与后台支付管理页 Co-authored-by: Cursor <cursoragent@cursor.com>
180 lines
5.3 KiB
PHP
180 lines
5.3 KiB
PHP
<?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) : '';
|
|
}
|
|
}
|