CRMEB/backend/app/services/system/SystemRouteDocBuilder.php
2026-11-12 16:49:58 +08:00

1423 lines
53 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace app\services\system;
use app\dao\system\SystemRouteDao;
use crmeb\basic\BaseService;
use crmeb\services\CacheService;
/**
* 根据控制器源码补齐 system_route 接口文档字段
*/
class SystemRouteDocBuilder extends BaseService
{
/** @var SystemRouteDao */
protected $dao;
/** @var array<string,string> */
protected $fieldTips = [
'id' => '主键ID',
'ids' => 'ID集合,多个用逗号分隔',
'uid' => '用户UID',
'page' => '页码,从1开始',
'limit' => '每页条数',
'status' => '状态',
'keyword' => '搜索关键词',
'name' => '名称',
'title' => '标题',
'type' => '类型',
'account' => '账号',
'pwd' => '密码',
'password' => '密码',
'phone' => '手机号',
'code' => '验证码/编码',
'image' => '图片地址',
'images' => '图片列表',
'content' => '内容',
'mark' => '备注',
'sort' => '排序值,越大越靠前',
'cate_id' => '分类ID',
'pid' => '上级ID',
'product_id' => '商品ID',
'order_id' => '订单号/订单ID',
'order_no' => '订单号',
'prompt' => '提示词',
'prompts' => '系统提示词数组',
'model_id' => '模型ID,0表示默认模型',
'token' => '令牌',
'key' => '唯一键/验证标识',
'captchaVerification' => '行为验证码二次校验串',
'captchaType' => '验证码类型',
'data' => '提交数据',
'value' => '配置值',
'field' => '字段名',
'start_time' => '开始时间',
'end_time' => '结束时间',
'date' => '日期',
'is_show' => '是否显示:1显示 0隐藏',
'is_del' => '是否删除:1是 0否',
'real_name' => '真实姓名',
'nickname' => '昵称',
'avatar' => '头像',
'price' => '价格',
'stock' => '库存',
'num' => '数量',
'remark' => '备注',
'describe' => '描述',
'description' => '说明',
'url' => '链接地址',
'path' => '路径',
'method' => '请求方法',
'appid' => '应用AppId',
'appsecret' => '应用密钥',
'access_token' => '访问令牌',
'refresh_token' => '刷新令牌',
'Authori-zation' => '鉴权头,格式 Bearer {token}',
'authorization' => '鉴权头,格式 Bearer {token}',
'token' => '登录令牌',
'expires_time' => '令牌过期时间戳',
'menus' => '菜单树',
'unique_auth' => '权限标识列表',
'user_info' => '当前用户信息',
'logo' => '站点Logo',
'logo_square' => '方形Logo',
'version' => '系统版本',
'site_name' => '站点名称',
'list' => '列表数据',
'count' => '总条数',
'info' => '详情对象',
'model' => '模型标识',
'content' => '内容',
'ok' => '是否成功',
'success' => '是否成功',
];
/** @var array<string, string> 控制器源码缓存 */
protected $fileCache = [];
public function __construct(SystemRouteDao $dao)
{
$this->dao = $dao;
}
/**
* 补齐指定应用文档
* @param string $app
* @return array{total:int,updated:int,skipped:int}
*/
public function build(string $app = 'adminapi'): array
{
$root = app()->getRootPath();
$list = $this->dao->selectList(['app_name' => $app], '*')->toArray();
$updated = 0;
$skipped = 0;
$sourceCache = [];
foreach ($list as $route) {
$file = $root . ltrim(str_replace('\\', '/', (string)$route['file_path']), '/');
if (!is_file($file)) {
$skipped++;
continue;
}
if (!isset($sourceCache[$file])) {
$sourceCache[$file] = (string)file_get_contents($file);
}
$source = $sourceCache[$file];
$action = (string)($route['action'] ?? '');
$methodSource = $this->extractMethodSource($source, $action);
$doc = $this->buildDoc($route, $methodSource, $source, $app);
$this->dao->update((int)$route['id'], $doc);
$updated++;
}
CacheService::clear();
return ['total' => count($list), 'updated' => $updated, 'skipped' => $skipped];
}
/**
* @param array $route
* @param string $methodSource
* @param string $fileSource
* @param string $app
* @return array
*/
protected function buildDoc(array $route, string $methodSource, string $fileSource, string $app): array
{
$describe = $this->extractDescribe($methodSource, $fileSource, $action = (string)$route['action'], (string)$route['name']);
$moreParams = $this->extractMoreParams($methodSource);
$pathParams = $this->extractPathParams((string)$route['path']);
$method = strtoupper((string)$route['method']);
$isWrite = in_array($method, ['POST', 'PUT', 'DELETE'], true);
$query = [];
$request = [];
foreach ($pathParams as $param) {
$query[] = $param;
}
foreach ($moreParams as $param) {
if ($isWrite && $method !== 'DELETE') {
// DELETE 多数走 query;POST/PUT 默认 body
$request[] = $param;
} else {
$query[] = $param;
}
}
// 若方法里同时出现 getMore + postMore,按调用分别归类
$split = $this->extractMoreParamsByKind($methodSource);
if ($split['get'] || $split['post']) {
$query = array_merge($pathParams, $split['get']);
$request = $split['post'];
if ($method === 'GET' || $method === 'DELETE') {
$query = array_merge($query, $split['post']);
$request = [];
}
}
$header = [];
if (!$this->isPublicRoute($route)) {
$header[] = $this->param('Authori-zation', 'string', 1, '鉴权头,格式 Bearer {token}');
}
[$response, $dataExample] = $this->buildResponseDoc($route, $methodSource, $query, $request);
$requestExample = [];
foreach ($request as $item) {
$requestExample[$item['attribute']] = $item['value'] ?? $this->exampleValue($item['type']);
}
if (!$requestExample) {
foreach ($query as $item) {
if (($item['from'] ?? '') === 'path') {
continue;
}
$requestExample[$item['attribute']] = $item['value'] ?? $this->exampleValue($item['type']);
}
}
$responseExample = [[
'name' => '成功',
'data' => [
'status' => 200,
'msg' => 'ok',
'data' => $dataExample,
],
]];
$errorCode = [
$this->param('400', 'string', 0, '参数错误或业务校验失败'),
$this->param('401', 'string', 0, '未登录或 Token 失效'),
$this->param('410000', 'string', 0, '无权限访问'),
];
$requestType = $isWrite ? 'application/json' : 'application/x-www-form-urlencoded';
return [
'describe' => $describe,
'header' => $header,
'query' => $this->uniqueParams($query),
'request' => $this->uniqueParams($request),
'request_type' => $requestType,
'response' => $response,
'request_example' => $requestExample,
'response_example' => $responseExample,
'error_code' => $errorCode,
];
}
/**
* @param string $source
* @param string $action
* @return string
*/
protected function extractMethodSource(string $source, string $action): string
{
if ($action === '') {
return '';
}
$pattern = '/function\s+' . preg_quote($action, '/') . '\s*\([^)]*\)\s*(?::\s*[^{\s]+)?\s*\{/';
if (!preg_match($pattern, $source, $m, PREG_OFFSET_CAPTURE)) {
return '';
}
$start = $m[0][1];
$bracePos = strpos($source, '{', $start);
if ($bracePos === false) {
return '';
}
$i = $bracePos;
$depth = 0;
$len = strlen($source);
for (; $i < $len; $i++) {
$ch = $source[$i];
if ($ch === '{') {
$depth++;
} elseif ($ch === '}') {
$depth--;
if ($depth === 0) {
// 向前取方法注释
$docStart = $this->findDocblockStart($source, $start);
return substr($source, $docStart, $i - $docStart + 1);
}
}
}
return '';
}
protected function findDocblockStart(string $source, int $methodPos): int
{
$before = substr($source, 0, $methodPos);
// 取方法正前方最近的一个 docblock,避免命中类注释
if (preg_match_all('/\/\*\*[\s\S]*?\*\//', $before, $matches, PREG_OFFSET_CAPTURE)) {
$last = end($matches[0]);
$docStart = (int)$last[1];
$between = trim(substr($before, $docStart + strlen($last[0])));
// docblock 与 function 之间只允许可见性修饰符
if ($between === '' || preg_match('/^(public|protected|private)\s*$/', $between)) {
return $docStart;
}
}
return $methodPos;
}
protected function extractDescribe(string $methodSource, string $fileSource, string $action, string $fallback): string
{
// 仅取方法源码开头的第一个 docblock(已裁剪为当前方法)
if (preg_match('/^\s*\/\*\*([\s\S]*?)\*\//', $methodSource, $m)) {
$lines = preg_split('/\R/', $m[1]) ?: [];
$parts = [];
foreach ($lines as $line) {
$line = trim($line);
$line = preg_replace('/^\*\s?/', '', $line) ?? '';
$line = trim($line);
if ($line === '' || str_starts_with($line, '@') || str_starts_with($line, 'Class ')) {
continue;
}
$parts[] = $line;
}
if ($parts) {
return implode("\n", $parts);
}
}
return $fallback !== '' ? $fallback : ($action !== '' ? $action : '接口说明');
}
/**
* @return array<int, array>
*/
protected function extractMoreParams(string $methodSource): array
{
$all = [];
foreach ($this->extractMoreParamsByKind($methodSource) as $items) {
foreach ($items as $item) {
$all[] = $item;
}
}
return $this->uniqueParams($all);
}
/**
* @return array{get:array,post:array}
*/
protected function extractMoreParamsByKind(string $methodSource): array
{
$result = ['get' => [], 'post' => []];
if ($methodSource === '') {
return $result;
}
if (preg_match_all('/->\s*(getMore|postMore)\s*\(/', $methodSource, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $idx => $full) {
$kind = strtolower($matches[1][$idx][0]) === 'getmore' ? 'get' : 'post';
$openParen = $full[1] + strlen($full[0]) - 1;
$argSource = $this->sliceBalanced($methodSource, $openParen, '(', ')');
// 取第一个数组字面量
$arrayPos = strpos($argSource, '[');
if ($arrayPos === false) {
continue;
}
$arrayBody = $this->sliceBalanced($argSource, $arrayPos, '[', ']');
// 去掉最外层 []
$inner = substr($arrayBody, 1, -1);
$result[$kind] = array_merge($result[$kind], $this->parseMoreArrayBody($inner));
}
}
return [
'get' => $this->uniqueParams($result['get']),
'post' => $this->uniqueParams($result['post']),
];
}
/**
* 截取成对括号/中括号内容(含起止符)
*/
protected function sliceBalanced(string $source, int $start, string $open, string $close): string
{
$depth = 0;
$len = strlen($source);
$inStr = false;
$quote = '';
for ($i = $start; $i < $len; $i++) {
$ch = $source[$i];
$prev = $i > 0 ? $source[$i - 1] : '';
if ($inStr) {
if ($ch === $quote && $prev !== '\\') {
$inStr = false;
}
continue;
}
if ($ch === "'" || $ch === '"') {
$inStr = true;
$quote = $ch;
continue;
}
if ($ch === $open) {
$depth++;
} elseif ($ch === $close) {
$depth--;
if ($depth === 0) {
return substr($source, $start, $i - $start + 1);
}
}
}
return '';
}
/**
* @return array<int, array>
*/
protected function parseMoreArrayBody(string $body): array
{
$params = [];
// ['name', 'default'] 或 ['name', 0] 或 ['name', []]
if (preg_match_all("/\[\s*['\"]([^'\"]+)['\"]\s*,\s*([^\[\]]+|\[[^\]]*\])\s*\]/", $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$name = $m[1];
$defaultRaw = trim($m[2]);
[$type, $value] = $this->inferTypeAndValue($defaultRaw);
$must = $this->isLikelyRequired($name, $value) ? 1 : 0;
$params[] = $this->param($name, $type, $must, $this->tip($name), $value);
}
}
return $params;
}
/**
* @return array<int, array>
*/
protected function extractPathParams(string $path): array
{
$params = [];
if (preg_match_all('/<([^>]+)>/', $path, $matches)) {
foreach ($matches[1] as $raw) {
$optional = str_ends_with($raw, '?');
$name = rtrim($raw, '?');
$params[] = $this->param($name, 'string', $optional ? 0 : 1, $this->tip($name) . ($optional ? '(可选路径参数)' : '(路径参数)'), '', 'path');
}
}
return $params;
}
/**
* @return array{0:string,1:mixed}
*/
protected function inferTypeAndValue(string $defaultRaw): array
{
$raw = rtrim($defaultRaw, ',');
if ($raw === '[]' || str_starts_with($raw, '[')) {
return ['array', []];
}
if (preg_match('/^-?\d+$/', $raw)) {
return ['number', (int)$raw];
}
if (preg_match('/^-?\d+\.\d+$/', $raw)) {
return ['number', (float)$raw];
}
if ($raw === 'true' || $raw === 'false') {
return ['boolean', $raw === 'true'];
}
if ($raw === 'null') {
return ['string', null];
}
if (preg_match("/^['\"](.*)['\"]$/s", $raw, $m)) {
return ['string', $m[1]];
}
return ['string', ''];
}
protected function tip(string $name): string
{
if (isset($this->fieldTips[$name])) {
return $this->fieldTips[$name];
}
// user_name -> 用户名风格弱提示
$text = str_replace('_', ' ', $name);
return $text;
}
/**
* 构建返回参数:status/msg/data,data 下至少再展开两层(共三层)
* @param array $route
* @param string $methodSource
* @param array $query
* @param array $request
* @return array{0:array,1:array|\stdClass}
*/
protected function buildResponseDoc(array $route, string $methodSource, array $query, array $request): array
{
$dataId = uniqid('data_', true);
$status = $this->param('status', 'number', 0, '业务状态码,200成功');
$msg = $this->param('msg', 'string', 0, '提示信息');
$data = $this->param('data', 'object', 0, '业务数据');
$data['id'] = $dataId;
$fields = $this->extractResponseDataFields($route, $methodSource, $query, $request);
$fields = $this->ensureThreeLevelFields($fields, $request, $route);
[$flat, $example] = $this->flattenFieldTree($fields, $dataId);
if (!$flat) {
$resultId = uniqid('p_', true);
$itemId = uniqid('p_', true);
$flat = [
array_merge($this->param('result', 'object', 0, '业务结果对象'), ['id' => $resultId, 'parentId' => $dataId]),
array_merge($this->param('item', 'object', 0, '结果明细'), ['id' => $itemId, 'parentId' => $resultId]),
array_merge($this->param('id', 'number', 0, '主键ID'), ['parentId' => $itemId]),
array_merge($this->param('name', 'string', 0, '名称'), ['parentId' => $itemId]),
];
$example = ['result' => ['item' => ['id' => 0, 'name' => '']]];
}
$response = array_merge([$status, $msg, $data], $flat);
return [$response, $example];
}
/**
* 将嵌套字段树压平为 parentId 列表,并生成示例
* @param array $fields
* @param string $parentId
* @return array{0:array,1:array}
*/
protected function flattenFieldTree(array $fields, string $parentId): array
{
$flat = [];
$example = [];
foreach ($fields as $field) {
$name = (string)($field['attribute'] ?? '');
if ($name === '') {
continue;
}
$type = (string)($field['type'] ?? 'string');
$row = $this->param($name, $type, 0, (string)($field['trip'] ?? $this->tip($name)));
$row['parentId'] = $parentId;
$id = $row['id'];
$flat[] = $row;
$children = $field['children'] ?? [];
if ($children) {
[$childFlat, $childExample] = $this->flattenFieldTree($children, $id);
$flat = array_merge($flat, $childFlat);
if ($type === 'array') {
$example[$name] = [$childExample];
} else {
$example[$name] = $childExample;
}
} else {
$example[$name] = $field['value'] ?? $this->exampleValue($type);
}
}
return [$flat, $example];
}
/**
* 保证 data 下字段树至少两层(整体三层:data -> 字段 -> 子字段)
* @param array $fields
* @param array $request
* @param array $route
* @return array
*/
protected function ensureThreeLevelFields(array $fields, array $request, array $route): array
{
if (!$fields) {
$fields = [
[
'attribute' => 'result',
'type' => 'object',
'trip' => '业务结果对象',
'children' => [],
],
];
}
$itemSeed = $this->buildItemSeedFields($request, $route);
foreach ($fields as &$field) {
$type = (string)($field['type'] ?? 'string');
$children = $field['children'] ?? [];
if (in_array($type, ['object', 'array'], true)) {
if (!$children) {
$children = $this->defaultChildrenForField((string)$field['attribute'], $type, $itemSeed);
} else {
// 已有一层子节点时,若子节点仍是 object/array 且无孙节点,继续补齐
foreach ($children as &$child) {
$childType = (string)($child['type'] ?? 'string');
if (in_array($childType, ['object', 'array'], true) && empty($child['children'])) {
$child['children'] = $this->defaultChildrenForField((string)$child['attribute'], $childType, $itemSeed);
}
}
unset($child);
}
// array 统一包一层 item,便于形成三层
if ($type === 'array') {
$hasItem = false;
foreach ($children as $child) {
if (($child['attribute'] ?? '') === 'item') {
$hasItem = true;
break;
}
}
if (!$hasItem) {
// 若 children 已是标量字段,视为 item 下字段
$looksLikeItemFields = true;
foreach ($children as $child) {
if (!empty($child['children'])) {
$looksLikeItemFields = false;
break;
}
}
if ($looksLikeItemFields) {
$children = [[
'attribute' => 'item',
'type' => 'object',
'trip' => '列表单项',
'children' => $children ?: $itemSeed,
]];
}
}
}
$field['children'] = $children;
}
}
unset($field);
// 若全部都是标量(只有两层),包进 result.item
$allScalar = true;
foreach ($fields as $field) {
if (!empty($field['children']) || in_array(($field['type'] ?? ''), ['object', 'array'], true)) {
$allScalar = false;
break;
}
}
if ($allScalar) {
$fields = [[
'attribute' => 'result',
'type' => 'object',
'trip' => '业务结果',
'children' => [[
'attribute' => 'item',
'type' => 'object',
'trip' => '结果明细',
'children' => $fields,
]],
]];
}
return $fields;
}
/**
* @param array $request
* @param array $route
* @return array<int, array>
*/
protected function buildItemSeedFields(array $request, array $route): array
{
$seed = [
['attribute' => 'id', 'type' => 'number', 'trip' => '主键ID', 'value' => 0],
];
$seen = ['id' => true];
foreach ($request as $item) {
$name = (string)($item['attribute'] ?? '');
if ($name === '' || isset($seen[$name]) || in_array($name, ['page', 'limit', 'keyword', 'Authori-zation', 'pwd', 'password', 'key', 'captchaVerification', 'captchaType', 'code'], true)) {
continue;
}
$seen[$name] = true;
$seed[] = [
'attribute' => $name,
'type' => (string)($item['type'] ?? 'string'),
'trip' => (string)($item['trip'] ?? $this->tip($name)),
];
if (count($seed) >= 8) {
break;
}
}
if (count($seed) < 3) {
foreach (['name' => '名称', 'status' => '状态', 'create_time' => '创建时间'] as $k => $tip) {
if (isset($seen[$k])) {
continue;
}
$seed[] = [
'attribute' => $k,
'type' => $this->guessFieldType($k),
'trip' => $tip,
];
}
}
return $seed;
}
/**
* @param string $name
* @param string $type
* @param array $itemSeed
* @return array
*/
protected function defaultChildrenForField(string $name, string $type, array $itemSeed): array
{
$n = strtolower($name);
// 字符串数组:元素为标量(data -> field -> item 即三层)
if (in_array($n, ['unique_auth', 'site_func', 'roles', 'permissions', 'ids', 'tags', 'labels'], true)
|| ($type === 'array' && str_ends_with($n, '_ids'))) {
return [[
'attribute' => 'item',
'type' => 'string',
'trip' => match ($n) {
'unique_auth' => '权限标识',
'site_func' => '站点功能标识',
'roles' => '角色标识',
'permissions' => '权限标识',
default => '数组元素',
},
'value' => '',
]];
}
if ($type === 'array' || in_array($n, ['list', 'menus', 'items', 'rows', 'children', 'tabs'], true)) {
return [[
'attribute' => 'item',
'type' => 'object',
'trip' => '数组单项',
'children' => $this->defaultObjectChildren($n, $itemSeed),
]];
}
return $this->defaultObjectChildren($n, $itemSeed);
}
/**
* @param string $name
* @param array $itemSeed
* @return array
*/
protected function defaultObjectChildren(string $name, array $itemSeed): array
{
if (in_array($name, ['user_info', 'user', 'admin_info'], true)) {
return [
['attribute' => 'id', 'type' => 'number', 'trip' => '用户ID', 'value' => 0],
['attribute' => 'account', 'type' => 'string', 'trip' => '账号'],
['attribute' => 'real_name', 'type' => 'string', 'trip' => '真实姓名'],
['attribute' => 'head_pic', 'type' => 'string', 'trip' => '头像'],
['attribute' => 'level', 'type' => 'number', 'trip' => '等级/身份'],
];
}
if (in_array($name, ['menus', 'menu'], true)) {
return [
['attribute' => 'id', 'type' => 'number', 'trip' => '菜单ID', 'value' => 0],
['attribute' => 'menu_name', 'type' => 'string', 'trip' => '菜单名称'],
['attribute' => 'menu_path', 'type' => 'string', 'trip' => '路由路径'],
['attribute' => 'children', 'type' => 'array', 'trip' => '子菜单', 'children' => [
['attribute' => 'item', 'type' => 'object', 'trip' => '子菜单项', 'children' => [
['attribute' => 'id', 'type' => 'number', 'trip' => '菜单ID', 'value' => 0],
['attribute' => 'menu_name', 'type' => 'string', 'trip' => '菜单名称'],
]],
]],
];
}
if (in_array($name, ['list', 'items', 'rows', 'tabs'], true)) {
return [
['attribute' => 'id', 'type' => 'number', 'trip' => '主键ID', 'value' => 0],
['attribute' => 'name', 'type' => 'string', 'trip' => '名称'],
['attribute' => 'status', 'type' => 'number', 'trip' => '状态'],
['attribute' => 'create_time', 'type' => 'string', 'trip' => '创建时间'],
];
}
if ($name === 'item' || $name === 'result' || $name === 'info' || $name === 'detail') {
// 避免把请求参数(账号密码验证码)误当成返回明细
$clean = [];
foreach ($itemSeed as $row) {
$attr = (string)($row['attribute'] ?? '');
if (in_array($attr, ['pwd', 'password', 'key', 'captchaVerification', 'captchaType', 'code'], true)) {
continue;
}
$clean[] = $row;
}
if (count($clean) < 2) {
$clean = [
['attribute' => 'id', 'type' => 'number', 'trip' => '主键ID', 'value' => 0],
['attribute' => 'name', 'type' => 'string', 'trip' => '名称'],
['attribute' => 'status', 'type' => 'number', 'trip' => '状态'],
];
}
return $clean;
}
return $itemSeed;
}
/**
* 从 success()/compact()/Service 返回值/列表约定中推断 data 字段
* @return array<int, array{attribute:string,type?:string,trip?:string,value?:mixed}>
*/
protected function extractResponseDataFields(array $route, string $methodSource, array $query, array $request): array
{
$fields = [];
$action = strtolower((string)($route['action'] ?? ''));
$path = strtolower((string)($route['path'] ?? ''));
$fileSource = $this->readFileSource(app()->getRootPath() . ltrim(str_replace('\\', '/', (string)($route['file_path'] ?? '')), '/'));
// 1) success([ ... ]) / success('msg', [ ... ])
foreach ($this->extractSuccessArrayFields($methodSource) as $field) {
$fields[] = $field;
}
// 2) success(compact('a', 'b'))
if (!$fields && preg_match_all('/compact\s*\(([^)]*)\)/', $methodSource, $compacts)) {
foreach ($compacts[1] as $args) {
if (preg_match_all('/[\'\"]([a-zA-Z_][\w]*)[\'\"]/', $args, $names)) {
foreach ($names[1] as $name) {
$fields[] = [
'attribute' => $name,
'type' => $this->guessFieldType($name),
'trip' => $this->tip($name),
];
}
}
}
}
// 3) success($var) / success($this->services->xxx()):追变量赋值或 Service 方法返回
if (!$fields) {
foreach ($this->extractSuccessExpressions($methodSource) as $expr) {
$resolved = $this->resolveExpressionFields($expr, $methodSource, $fileSource);
if ($resolved) {
$fields = $resolved;
break;
}
}
}
// 4) 列表类:getList / index / list
$isList = (bool)preg_match('/getList|get_list|->list\(/', $methodSource)
|| in_array($action, ['index', 'list', 'getlist', 'lst'], true)
|| str_ends_with($path, '/list')
|| str_contains($path, '/lst');
if (!$fields && $isList) {
$itemSeed = $this->buildItemSeedFields($request, $route);
$fields = [
[
'attribute' => 'list',
'type' => 'array',
'trip' => '列表数据',
'children' => [[
'attribute' => 'item',
'type' => 'object',
'trip' => '列表单项',
'children' => $itemSeed,
]],
],
['attribute' => 'count', 'type' => 'number', 'trip' => '总条数', 'value' => 0],
];
}
// 5) SSE / 流式
if (!$fields && (str_contains($methodSource, 'text/event-stream') || str_contains($methodSource, 'event-stream'))) {
$fields = [
[
'attribute' => 'event',
'type' => 'object',
'trip' => 'SSE 事件载荷',
'children' => [
['attribute' => 'type', 'type' => 'string', 'trip' => '事件类型:start/delta/reasoning/done/error'],
['attribute' => 'content', 'type' => 'string', 'trip' => '增量文本内容'],
['attribute' => 'msg', 'type' => 'string', 'trip' => '错误信息(error 时)'],
],
],
];
}
// 6) 仅返回文案:success('xxx') —— data 通常为空数组
if (!$fields && preg_match('/->\s*success\s*\(\s*[\'\"][^\'\"]+[\'\"]\s*\)/', $methodSource)) {
$fields = [
[
'attribute' => 'result',
'type' => 'object',
'trip' => '操作结果(该接口 data 常为空,以 msg 为准)',
'children' => [
['attribute' => 'ok', 'type' => 'boolean', 'trip' => '是否成功', 'value' => true],
['attribute' => 'message', 'type' => 'string', 'trip' => '结果说明'],
],
],
];
}
// 7) 详情类兜底:用请求体字段包一层 info
if (!$fields && (in_array($action, ['read', 'info', 'detail', 'edit', 'create'], true) || str_contains($path, '/<id>'))) {
$itemSeed = $this->buildItemSeedFields($request, $route);
$fields = [[
'attribute' => 'info',
'type' => 'object',
'trip' => '详情对象',
'children' => $itemSeed,
]];
}
return $this->uniqueResponseFields($fields);
}
/**
* @return array<int, string>
*/
protected function extractSuccessExpressions(string $methodSource): array
{
$exprs = [];
if (!preg_match_all('/->\s*success\s*\(/', $methodSource, $matches, PREG_OFFSET_CAPTURE)) {
return $exprs;
}
foreach ($matches[0] as $match) {
$open = $match[1] + strlen($match[0]) - 1;
$args = $this->sliceBalanced($methodSource, $open, '(', ')');
if ($args === '') {
continue;
}
$inner = trim(substr($args, 1, -1));
if ($inner === '') {
continue;
}
// 跳过纯字符串消息
if ($inner[0] === "'" || $inner[0] === '"') {
$comma = $this->findTopLevelComma($inner);
if ($comma === null) {
continue;
}
$inner = trim(substr($inner, $comma + 1));
}
if ($inner !== '' && $inner[0] !== '[') {
$exprs[] = rtrim($inner, ", \t\n\r");
}
}
return $exprs;
}
/**
* 解析表达式对应返回字段
* @return array<int, array>
*/
protected function resolveExpressionFields(string $expr, string $methodSource, string $controllerSource): array
{
$expr = trim($expr);
// $this->services->getList(...) / $this->service->read($id)
if (preg_match('/\$this->(\w+)\s*->\s*(\w+)\s*\(/', $expr, $m)) {
return $this->resolveServiceMethodFields($m[1], $m[2], $controllerSource);
}
// $var
if (preg_match('/^\$([a-zA-Z_][\w]*)$/', $expr, $m)) {
$var = $m[1];
// 找方法内最后一次赋值
if (preg_match_all('/\$' . preg_quote($var, '/') . '\s*=\s*([^;]+);/', $methodSource, $assigns)) {
$rhs = trim(end($assigns[1]));
if (str_starts_with($rhs, '[')) {
return $this->fieldsFromArrayLiteral($rhs);
}
if (preg_match('/compact\s*\(([^)]*)\)/', $rhs, $c)) {
$fields = [];
if (preg_match_all('/[\'\"]([a-zA-Z_][\w]*)[\'\"]/', $c[1], $names)) {
foreach ($names[1] as $name) {
$fields[] = [
'attribute' => $name,
'type' => $this->guessFieldType($name),
'trip' => $this->tip($name),
];
}
}
return $fields;
}
if (preg_match('/\$this->(\w+)\s*->\s*(\w+)\s*\(/', $rhs, $sm)) {
return $this->resolveServiceMethodFields($sm[1], $sm[2], $controllerSource);
}
}
}
return [];
}
/**
* @return array<int, array>
*/
protected function resolveServiceMethodFields(string $prop, string $method, string $controllerSource): array
{
// 列表约定
if (in_array(strtolower($method), ['getlist', 'list', 'getlistforadmin', 'getpagelist'], true)) {
return [
[
'attribute' => 'list',
'type' => 'array',
'trip' => '列表数据',
'children' => [[
'attribute' => 'item',
'type' => 'object',
'trip' => '列表单项',
'children' => [
['attribute' => 'id', 'type' => 'number', 'trip' => '主键ID', 'value' => 0],
['attribute' => 'name', 'type' => 'string', 'trip' => '名称'],
['attribute' => 'status', 'type' => 'number', 'trip' => '状态'],
],
]],
],
['attribute' => 'count', 'type' => 'number', 'trip' => '总条数', 'value' => 0],
];
}
$class = $this->resolveServiceClass($prop, $controllerSource);
if ($class === '') {
return [];
}
$path = $this->classToPath($class);
if ($path === '' || !is_file($path)) {
return [];
}
$serviceSource = $this->readFileSource($path);
$methodSource = $this->extractMethodSource($serviceSource, $method);
if ($methodSource === '') {
return [];
}
// return [ ... ](用括号平衡截取,避免嵌套数组截断)
if (preg_match_all('/return\s*\[/', $methodSource, $returns, PREG_OFFSET_CAPTURE)) {
foreach (array_reverse($returns[0]) as $hit) {
$pos = strpos($methodSource, '[', $hit[1]);
if ($pos === false) {
continue;
}
$literal = $this->sliceBalanced($methodSource, $pos, '[', ']');
$fields = $this->fieldsFromArrayLiteral($literal);
if ($fields) {
return $fields;
}
}
}
// return compact(...)
if (preg_match('/return\s+compact\s*\(([^)]*)\)/', $methodSource, $c)) {
$fields = [];
if (preg_match_all('/[\'\"]([a-zA-Z_][\w]*)[\'\"]/', $c[1], $names)) {
foreach ($names[1] as $name) {
$fields[] = [
'attribute' => $name,
'type' => $this->guessFieldType($name),
'trip' => $this->tip($name),
];
}
}
return $fields;
}
// getDetail/read/info 常见回详情对象
if (in_array(strtolower($method), ['getdetail', 'read', 'info', 'getinfo', 'detail'], true)) {
return [[
'attribute' => 'info',
'type' => 'object',
'trip' => '详情对象',
'children' => [
['attribute' => 'id', 'type' => 'number', 'trip' => '主键ID', 'value' => 0],
['attribute' => 'name', 'type' => 'string', 'trip' => '名称'],
['attribute' => 'status', 'type' => 'number', 'trip' => '状态'],
['attribute' => 'create_time', 'type' => 'string', 'trip' => '创建时间'],
],
]];
}
return [];
}
protected function resolveServiceClass(string $prop, string $controllerSource): string
{
$short = '';
// @var Foo $services
if (preg_match('/@var\s+([\\\\\w]+)\s+\$' . preg_quote($prop, '/') . '\b/', $controllerSource, $m)) {
$short = ltrim($m[1], '\\');
}
// function __construct(... Foo $services)
if ($short === '' && preg_match('/function\s+__construct\s*\(([^)]*)\)/', $controllerSource, $m)) {
if (preg_match('/([\\\\\w]+)\s+\$' . preg_quote($prop, '/') . '\b/', $m[1], $mm)) {
$short = ltrim($mm[1], '\\');
}
}
// $this->services = app()->make(Foo::class)
if ($short === '' && preg_match('/\$this->' . preg_quote($prop, '/') . '\s*=\s*app\(\)->make\(\s*([\\\\\w]+)::class/', $controllerSource, $m)) {
$short = ltrim($m[1], '\\');
}
if ($short === '') {
return '';
}
return $this->expandClassName($short, $controllerSource);
}
/**
* 结合 use / 当前 namespace 展开短类名
*/
protected function expandClassName(string $class, string $source): string
{
$class = ltrim($class, '\\');
if (str_contains($class, '\\')) {
return $class;
}
// use Foo\Bar\SystemAdminServices;
// use Foo\Bar\SystemAdminServices as AdminServices;
if (preg_match('/use\s+([\\\\\w]+)\\\\' . preg_quote($class, '/') . '\s*;/', $source, $m)) {
return ltrim($m[1] . '\\' . $class, '\\');
}
if (preg_match('/use\s+([\\\\\w]+)\s+as\s+' . preg_quote($class, '/') . '\s*;/', $source, $m)) {
return ltrim($m[1], '\\');
}
if (preg_match('/namespace\s+([\\\\\w]+)\s*;/', $source, $m)) {
return ltrim($m[1] . '\\' . $class, '\\');
}
return $class;
}
protected function classToPath(string $class): string
{
$rel = ltrim(str_replace('\\', '/', $class), '/');
$root = app()->getRootPath();
$candidates = [
$root . $rel . '.php',
];
foreach ($candidates as $file) {
if (is_file($file)) {
return $file;
}
}
return $root . $rel . '.php';
}
protected function readFileSource(string $file): string
{
if ($file === '' || !is_file($file)) {
return '';
}
if (!isset($this->fileCache[$file])) {
$this->fileCache[$file] = (string)file_get_contents($file);
}
return $this->fileCache[$file];
}
/**
* 递归提取数组字面量字段树(含嵌套 object)
* @return array<int, array>
*/
protected function fieldsFromArrayLiteral(string $literal): array
{
$fields = [];
$literal = trim($literal);
if ($literal === '' || $literal[0] !== '[') {
return $fields;
}
$wrapped = $this->sliceBalanced($literal, 0, '[', ']');
if ($wrapped === '') {
return $fields;
}
$body = substr($wrapped, 1, -1);
$depth = 0;
$inStr = false;
$quote = '';
$len = strlen($body);
for ($i = 0; $i < $len; $i++) {
$ch = $body[$i];
$prev = $i > 0 ? $body[$i - 1] : '';
if ($inStr) {
if ($ch === $quote && $prev !== '\\') {
$inStr = false;
}
continue;
}
if ($depth === 0 && preg_match('/\G[\'\"]([a-zA-Z_][\w]*)[\'\"]\s*=>/', $body, $m, 0, $i)) {
$name = $m[1];
$i += strlen($m[0]);
while ($i < $len && ctype_space($body[$i])) {
$i++;
}
$valueStart = $i;
$field = [
'attribute' => $name,
'type' => $this->guessFieldType($name),
'trip' => $this->tip($name),
];
if ($i < $len && $body[$i] === '[') {
$nestedLit = $this->sliceBalanced($body, $i, '[', ']');
$nestedFields = $this->fieldsFromArrayLiteral($nestedLit);
if ($nestedFields) {
$field['type'] = 'object';
$field['children'] = $nestedFields;
} else {
$field['type'] = 'array';
}
$i += max(0, strlen($nestedLit) - 1);
} else {
// 跳过非数组值,直到顶层逗号
$valueDepth = 0;
$valueInStr = false;
$valueQuote = '';
for (; $i < $len; $i++) {
$vc = $body[$i];
$vp = $i > 0 ? $body[$i - 1] : '';
if ($valueInStr) {
if ($vc === $valueQuote && $vp !== '\\') {
$valueInStr = false;
}
continue;
}
if ($vc === "'" || $vc === '"') {
$valueInStr = true;
$valueQuote = $vc;
continue;
}
if ($vc === '(' || $vc === '[' || $vc === '{') {
$valueDepth++;
continue;
}
if ($vc === ')' || $vc === ']' || $vc === '}') {
$valueDepth = max(0, $valueDepth - 1);
continue;
}
if ($vc === ',' && $valueDepth === 0) {
break;
}
}
// 根据值片段微调类型
$valueSnippet = trim(substr($body, $valueStart, max(0, $i - $valueStart)));
if (preg_match('/^\d+(\.\d+)?$/', $valueSnippet)) {
$field['type'] = 'number';
} elseif (in_array($valueSnippet, ['true', 'false'], true)) {
$field['type'] = 'boolean';
} elseif (str_starts_with($valueSnippet, '[')) {
$field['type'] = 'array';
}
}
$fields[] = $field;
continue;
}
if ($ch === "'" || $ch === '"') {
$inStr = true;
$quote = $ch;
continue;
}
if ($ch === '[' || $ch === '(' || $ch === '{') {
$depth++;
continue;
}
if ($ch === ']' || $ch === ')' || $ch === '}') {
$depth = max(0, $depth - 1);
}
}
return $fields;
}
/**
* 解析 success([ ... ]) 中的一级键
* @return array<int, array>
*/
protected function extractSuccessArrayFields(string $methodSource): array
{
$fields = [];
if (!preg_match_all('/->\s*success\s*\(/', $methodSource, $matches, PREG_OFFSET_CAPTURE)) {
return $fields;
}
foreach ($matches[0] as $match) {
$open = $match[1] + strlen($match[0]) - 1;
$args = $this->sliceBalanced($methodSource, $open, '(', ')');
if ($args === '') {
continue;
}
$inner = substr($args, 1, -1);
$innerTrim = ltrim($inner);
// success('msg', [ ... ]) 取第二个数组
if ($innerTrim !== '' && ($innerTrim[0] === "'" || $innerTrim[0] === '"')) {
$commaPos = $this->findTopLevelComma($inner);
if ($commaPos !== null) {
$inner = substr($inner, $commaPos + 1);
$innerTrim = ltrim($inner);
}
}
if ($innerTrim === '' || $innerTrim[0] !== '[') {
continue;
}
$arrayPos = strpos($inner, '[');
$arrayLit = $this->sliceBalanced($inner, (int)$arrayPos, '[', ']');
if ($arrayLit === '') {
continue;
}
$fields = $this->fieldsFromArrayLiteral($arrayLit);
if ($fields) {
break;
}
}
return $fields;
}
protected function findTopLevelComma(string $source): ?int
{
$depthParen = 0;
$depthBracket = 0;
$inStr = false;
$quote = '';
$len = strlen($source);
for ($i = 0; $i < $len; $i++) {
$ch = $source[$i];
$prev = $i > 0 ? $source[$i - 1] : '';
if ($inStr) {
if ($ch === $quote && $prev !== '\\') {
$inStr = false;
}
continue;
}
if ($ch === "'" || $ch === '"') {
$inStr = true;
$quote = $ch;
continue;
}
if ($ch === '(') {
$depthParen++;
} elseif ($ch === ')') {
$depthParen--;
} elseif ($ch === '[') {
$depthBracket++;
} elseif ($ch === ']') {
$depthBracket--;
} elseif ($ch === ',' && $depthParen === 0 && $depthBracket === 0) {
return $i;
}
}
return null;
}
protected function guessFieldType(string $name): string
{
$n = strtolower($name);
if (in_array($n, ['list', 'items', 'rows', 'images', 'ids', 'label_ids', 'children', 'menus', 'unique_auth', 'site_func', 'tabs'], true)
|| str_ends_with($n, '_list') || str_ends_with($n, '_ids')) {
return 'array';
}
if (in_array($n, ['count', 'total', 'page', 'limit', 'status', 'type', 'id', 'uid', 'pid', 'num', 'stock', 'sort', 'expires_time'], true)
|| str_ends_with($n, '_id') || str_ends_with($n, '_num') || str_ends_with($n, '_count') || str_ends_with($n, '_time')) {
return 'number';
}
if (str_starts_with($n, 'is_') || str_starts_with($n, 'has_') || in_array($n, ['ok', 'success', 'queue', 'timer'], true)) {
return in_array($n, ['ok', 'success', 'queue', 'timer'], true) ? 'boolean' : 'number';
}
if (in_array($n, ['info', 'detail', 'config', 'data', 'user', 'order', 'product', 'user_info', 'result'], true)
|| str_ends_with($n, '_info')) {
return 'object';
}
return 'string';
}
/**
* @param array $fields
* @return array
*/
protected function uniqueResponseFields(array $fields): array
{
$seen = [];
$out = [];
foreach ($fields as $item) {
$key = strtolower((string)($item['attribute'] ?? ''));
if ($key === '' || isset($seen[$key])) {
continue;
}
$seen[$key] = true;
if (!empty($item['children']) && is_array($item['children'])) {
$item['children'] = $this->uniqueResponseFields($item['children']);
}
$out[] = $item;
}
return $out;
}
/**
* 公共免鉴权接口(登录/验证码/升级入口等)
*/
protected function isPublicRoute(array $route): bool
{
if ((int)($route['type'] ?? 0) === 1) {
return true;
}
$path = (string)($route['path'] ?? '');
$exact = [
'login', 'login/info', 'login/mobile', 'captcha_pro', 'ajcaptcha', 'ajcheck',
'upgrade', 'upgrade/run', 'get_workerman_url', 'custom_admin_js', 'image/scan_upload',
'site_config', 'index', 'theme_version',
];
if (in_array($path, $exact, true)) {
return true;
}
return (bool)preg_match('#^(login/|register|verify_code|sms_captcha|pc/login|wechat/auth|v2/.*/auth_login)#', $path);
}
/**
* 常见必填字段
*/
protected function isLikelyRequired(string $name, $defaultValue): bool
{
$required = [
'account', 'pwd', 'password', 'prompt', 'phone', 'appid', 'appsecret',
'id', 'uid', 'order_id', 'order_no', 'product_id', 'code',
];
if (!in_array($name, $required, true)) {
return false;
}
return $defaultValue === '' || $defaultValue === null;
}
protected function exampleValue(string $type)
{
$t = strtolower($type);
if (str_contains($t, 'array') || str_contains($t, 'object')) {
return [];
}
if (str_contains($t, 'number') || str_contains($t, 'int')) {
return 0;
}
if (str_contains($t, 'bool')) {
return false;
}
return '';
}
protected function param(string $attribute, string $type, $must, string $trip, $value = '', string $from = ''): array
{
$row = [
'attribute' => $attribute,
'type' => $type,
'must' => (string)(int)$must,
'trip' => $trip,
'id' => uniqid('p_', true),
'parentId' => '',
];
if ($value !== '' || $value === 0 || $value === false || $value === []) {
$row['value'] = $value;
}
if ($from !== '') {
$row['from'] = $from;
}
return $row;
}
/**
* @param array $params
* @return array
*/
protected function uniqueParams(array $params): array
{
$seen = [];
$out = [];
foreach ($params as $item) {
$key = strtolower((string)($item['attribute'] ?? '')) . '|' . (string)($item['parentId'] ?? '');
if (($item['attribute'] ?? '') === '' || isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $item;
}
return $out;
}
}