Files
YwxAppThink/ywxapp/command/AddonCrud.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

1012 lines
41 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
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace ywxapp\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Argument;
use think\console\input\Option;
use think\facade\Db;
/**
* 插件后台 CRUD 生成器
*
* 由一张数据表生成插件后台整套增删改查代码,并自动注册路由与菜单权限节点:
* php think addon:crud <插件名> -t <表名> [--name=类名] [--title=菜单标题] [--force]
*
* 生成物:
* 1. addon/<插件>/model/<Model>.php 模型(表名/时间戳/软删除/类型转换)
* 2. addon/<插件>/controller/backend/<Ctrl>.php 控制器(index/add/update/delete)
* 3. addon/<插件>/view/backend/<ctrl>/index.html 列表页 + 弹窗表单模板
* 4. public/static/backend/modules/<ctrl>.js layui 表格 + 弹窗交互
* 5. addon/<插件>/route/app.php 追加 backend 路由(--no-route 可关闭)
* 6. addon/<插件>/menu.json 追加菜单与按钮权限节点(--no-menu 可关闭)
*
* 权限约定(与 Auth::verifyAuth / AddonService::saveMenus / auth.js 三方对齐):
* 菜单节点 backend_power.name = <插件>:backend:<控制器>/index
* 按钮节点 backend_power.name = <插件>:backend:<控制器>:add|update|delete
* 前端判定 auth.setController('<插件>:backend:<控制器>') + data-perm="add|update|delete"
* 后端判定 控制器动作名即 add/update/delete,与按钮节点一一对应
*
* @author ywxapp <admin@ywxapp.cn>
*/
class AddonCrud extends Command
{
/**
* 时间戳字段(不出现在表单、不参与白名单)
*/
private const TIME_FIELDS = ['create_at', 'update_at', 'delete_at', 'create_time', 'update_time', 'delete_time'];
/**
* 判为图片/文件的字段名(生成上传类输入框)
*/
private const MEDIA_FIELDS = ['cover', 'image', 'images', 'avatar', 'thumb', 'logo', 'photo', 'pic', 'banner', 'file', 'url'];
/**
* 判为大文本的字段名(生成 textarea)
*/
private const TEXT_FIELDS = ['content', 'detail', 'remark', 'description', 'intro', 'descr', 'summary', 'note', 'memo'];
/**
* 列表页可参与关键字搜索的字段名
*/
private const SEARCH_FIELDS = ['name', 'title', 'username', 'nickname', 'mobile', 'phone', 'email', 'keyword', 'alias', 'no', 'sn'];
/**
* {@inheritdoc}
*/
protected function configure()
{
$this->setName('addon:crud')
->addArgument('addon', Argument::OPTIONAL, '插件名,如 articles(--app=backend 时留空)')
->addOption('table', 't', Option::VALUE_REQUIRED, '数据表名(可省略表前缀)')
->addOption('app', null, Option::VALUE_OPTIONAL, '生成到核心后台:--app=backend(默认生成到插件)')
->addOption('name', null, Option::VALUE_OPTIONAL, '控制器 / 模型类名,默认由表名推导')
->addOption('title', null, Option::VALUE_OPTIONAL, '菜单标题,默认取表注释')
->addOption('icon', null, Option::VALUE_OPTIONAL, '菜单图标')
->addOption('pid', null, Option::VALUE_OPTIONAL, '核心后台菜单父级 ID,默认 0(顶级)')
->addOption('force', 'f', Option::VALUE_NONE, '覆盖已存在的同名文件')
->addOption('no-menu', null, Option::VALUE_NONE, '不写入 menu.json / 权限表')
->addOption('no-route', null, Option::VALUE_NONE, '不写入 route/app.php')
->setDescription('按数据表生成后台 CRUD:model / controller / view / js + 路由 + 菜单权限节点(插件或核心后台)');
}
protected function execute(Input $input, Output $output)
{
$addon = trim((string) $input->getArgument('addon'));
$table = trim((string) $input->getOption('table'));
$mode = strtolower(trim((string) $input->getOption('app'))) === 'backend' ? 'backend' : 'addon';
if ($table === '') {
$output->writeln('<error>请通过 -t/--table 指定数据表,示例:php think addon:crud articles -t articles_category</error>');
return 1;
}
$root = $this->app->getRootPath();
if ($mode === 'addon') {
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $addon)) {
$output->writeln('<error>插件名不合法,示例:php think addon:crud articles -t articles_category</error>');
return 1;
}
$addonDir = $root . 'addon' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR;
if (!is_dir($addonDir)) {
$output->writeln("<error>插件目录不存在:addon/{$addon},请先执行 php think addon:make {$addon} -a build</error>");
return 1;
}
$modelNs = 'addon\\' . $addon . '\\model';
$ctrlNs = 'addon\\' . $addon . '\\controller\\backend';
$modelDir = $addonDir . 'model';
$ctrlDir = $addonDir . 'controller' . DIRECTORY_SEPARATOR . 'backend';
$viewBase = $addonDir . 'view' . DIRECTORY_SEPARATOR . 'backend';
$routeFile = $addonDir . 'route' . DIRECTORY_SEPARATOR . 'app.php';
$defIcon = 'fa fa-list';
} else {
// 核心后台:app/backend/{model,controller,view,route}
$addon = '';
$addonDir = $root . 'app' . DIRECTORY_SEPARATOR . 'backend' . DIRECTORY_SEPARATOR;
$modelNs = 'app\\backend\\model';
$ctrlNs = 'app\\backend\\controller';
$modelDir = $addonDir . 'model';
$ctrlDir = $addonDir . 'controller';
$viewBase = $addonDir . 'view';
$routeFile = $addonDir . 'route' . DIRECTORY_SEPARATOR . 'app.php';
$defIcon = 'layui-icon layui-icon-template-1';
}
/* ---------------------------- 读取表结构 ---------------------------- */
$prefix = $this->tablePrefix();
$fullTable = str_starts_with($table, $prefix) ? $table : $prefix . $table;
$rawTable = substr($fullTable, strlen($prefix));
try {
$columns = Db::query('SHOW FULL COLUMNS FROM `' . $fullTable . '`');
} catch (\Throwable $e) {
$output->writeln('<error>读取表结构失败:' . $fullTable . '(' . $e->getMessage() . ')</error>');
return 1;
}
if (empty($columns)) {
$output->writeln("<error>表不存在或无字段:{$fullTable}</error>");
return 1;
}
$fields = $this->normalizeColumns($columns);
$uniques = $this->uniqueFields($fullTable);
$comment = $this->tableComment($fullTable);
/* ------------------------------ 命名推导 ------------------------------ */
// 表名去掉插件名前缀后再转驼峰:articles_category → Category(与 articles 插件命名一致)
$base = $rawTable;
if ($mode === 'addon' && str_starts_with($base, $addon . '_')) {
$base = substr($base, strlen($addon) + 1);
}
$class = $this->studly($input->getOption('name') ?: $base);
$viewDir = $this->snake($class); // Category → category;ArticleCategory → article_category
$route = $viewDir;
$varName = $this->camel($class); // 模板变量名 / 表单 filter 名
$title = trim((string) ($input->getOption('title') ?: '')) ?: ($comment ?: $class);
$icon = trim((string) ($input->getOption('icon') ?: '')) ?: $defIcon;
// 权限前缀:插件为 <插件>:backend:<控制器>;核心后台为 <控制器>
// (Auth::verifyAuth 对核心后台用 controller:action 精确匹配,且 controller 会转小写)
$perm = $mode === 'addon' ? ($addon . ':backend:' . $viewDir) : strtolower($class);
$force = (bool) $input->getOption('force');
$output->writeln($mode === 'addon'
? "<comment>表 {$fullTable} → 插件 {$addon} / 类 {$class}</comment>"
: "<comment>表 {$fullTable} → 核心后台 / 类 {$class}</comment>");
/* ------------------------------ 生成文件 ------------------------------ */
$ctx = [
'mode' => $mode,
'addon' => $addon,
'class' => $class,
'modelNs' => $modelNs, // 模型所在命名空间(不含类名)
'ctrlNs' => $ctrlNs, // 控制器所在命名空间
'table' => $rawTable,
'full' => $fullTable,
'viewDir' => $viewDir,
'route' => $route,
'var' => $varName,
'title' => $title,
'icon' => $icon,
'perm' => $perm,
// 按钮权限全名前缀:data-perm="{前缀}add" → 如 articles:backend:tag:add / database:add
// 核心后台既有页面(如 links)用完整权限名,插件后台同样适用,故统一用全名
'permPrefix' => $perm . ':',
'fields' => $fields,
'uniques' => $uniques,
'pk' => $this->primaryKey($fields),
];
$files = [
$modelDir . DIRECTORY_SEPARATOR . $class . '.php'
=> $this->renderModel($ctx),
$ctrlDir . DIRECTORY_SEPARATOR . $class . '.php'
=> $this->renderController($ctx),
$viewBase . DIRECTORY_SEPARATOR . $viewDir . DIRECTORY_SEPARATOR . 'index.html'
=> $this->renderView($ctx),
public_path() . 'static' . DIRECTORY_SEPARATOR . 'backend' . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . $viewDir . '.js'
=> $this->renderJs($ctx),
];
$written = 0;
foreach ($files as $path => $content) {
if (is_file($path) && !$force) {
$output->writeln('<comment>已存在,跳过(加 -f 覆盖):' . $this->short($path) . '</comment>');
continue;
}
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
file_put_contents($path, $this->clean($content));
$written++;
$output->writeln('<info>生成 ' . $this->short($path) . '</info>');
}
/* --------------------------- 路由 / 菜单 --------------------------- */
$routeTip = $mode === 'addon' ? 'addon/' . $addon . '/route/app.php' : 'app/backend/route/app.php';
if (!$input->getOption('no-route')) {
$res = $this->appendRoute($routeFile, $ctx);
$output->writeln($res === true
? '<info>已追加后台路由到 ' . $routeTip . '</info>'
: '<comment>' . $res . '</comment>');
}
if (!$input->getOption('no-menu')) {
if ($mode === 'addon') {
$res = $this->appendMenu($addonDir . 'menu.json', $ctx);
$ok = '<info>已追加菜单与按钮权限到 addon/' . $addon . '/menu.json</info>';
} else {
$res = $this->appendPower($ctx, (int) $input->getOption('pid'));
$ok = '<info>已写入菜单与按钮权限到 backend_power 表</info>';
}
$output->writeln($res === true ? $ok : '<comment>' . $res . '</comment>');
}
$output->writeln('');
$output->writeln("<info>CRUD 生成完成(写入 {$written} 个文件)</info>");
$output->writeln(' 权限前缀:' . $perm);
$output->writeln($mode === 'addon'
? ' 后续:php think addon:manage ' . $addon . ' -a reload # 重载菜单使新节点生效'
: ' 后续:刷新后台即可看到新菜单(核心后台权限直接读 backend_power)');
return 0;
}
/* ============================ 渲染:模型 ============================ */
protected function renderModel(array $ctx): string
{
$names = array_column($ctx['fields'], 'field');
$hasCreate = in_array('create_at', $names, true);
$hasUpdate = in_array('update_at', $names, true);
$hasDelete = in_array('delete_at', $names, true);
$use = [];
$traits = '';
if ($hasDelete) {
$use[] = 'use think\model\concern\SoftDelete;';
$traits = "\n use SoftDelete;\n";
}
$lines = [];
$lines[] = ' protected $name = \'' . $ctx['table'] . '\';';
if ($ctx['pk'] !== 'id') {
$lines[] = ' protected $pk = \'' . $ctx['pk'] . '\';';
}
if ($hasCreate || $hasUpdate) {
$lines[] = ' protected $autoWriteTimestamp = \'int\';';
$lines[] = ' protected $createTime = \'' . ($hasCreate ? 'create_at' : false) . '\';';
$lines[] = ' protected $updateTime = \'' . ($hasUpdate ? 'update_at' : false) . '\';';
} else {
$lines[] = ' protected $autoWriteTimestamp = false;';
}
if ($hasDelete) {
$lines[] = ' protected $deleteTime = \'delete_at\';';
$lines[] = ' protected $defaultSoftDelete = 0;';
}
$types = $this->modelTypes($ctx['fields']);
if ($types !== []) {
$lines[] = ' protected $type = [';
foreach ($types as $field => $type) {
$lines[] = ' \'' . $field . '\' => \'' . $type . '\',';
}
$lines[] = ' ];';
}
$stub = $this->stub('model.stub');
return str_replace(
['{%use%}', '{%traits%}', '{%namespace%}', '{%className%}', '{%table%}', '{%options%}'],
[
$use ? implode("\n", $use) . "\n" : '',
$traits,
$ctx['modelNs'],
$ctx['class'],
$ctx['table'],
implode("\n", $lines),
],
$stub
);
}
/* ========================== 渲染:控制器 ========================== */
protected function renderController(array $ctx): string
{
$model = $ctx['class'];
$editable = $this->editableFields($ctx['fields']);
$allow = implode(', ', array_map(fn($f) => '\'' . $f['field'] . '\'', $editable));
$rules = $this->validateRules($ctx);
$search = $this->searchSnippet($ctx['fields']);
$stub = $this->stub('controller.stub');
return str_replace(
['{%namespace%}', '{%addon%}', '{%className%}', '{%model%}', '{%modelNs%}', '{%modelAlias%}', '{%viewDir%}', '{%title%}', '{%pk%}', '{%allow%}', '{%rules%}', '{%search%}'],
[
$ctx['ctrlNs'],
$ctx['addon'],
$ctx['class'],
$model,
$ctx['modelNs'] . '\\' . $model,
$model . 'Model',
$ctx['viewDir'],
$ctx['title'],
$ctx['pk'],
$allow,
$rules,
$search,
],
$stub
);
}
/* ========================== 渲染:视图 ========================== */
protected function renderView(array $ctx): string
{
$editable = $this->editableFields($ctx['fields']);
$items = [];
foreach ($editable as $f) {
$items[] = $this->formItem($f, $ctx);
}
$hasStatus = false;
foreach ($ctx['fields'] as $f) {
if ($f['field'] === 'status') {
$hasStatus = true;
}
}
$searchBar = $this->searchBar($ctx['fields']);
$stub = $this->stub('view.index.stub');
return str_replace(
['{%title%}', '{%module%}', '{%var%}', '{%permPrefix%}', '{%searchBar%}', '{%statusTpl%}', '{%formItems%}'],
[
$ctx['title'],
$ctx['viewDir'],
$ctx['var'],
$ctx['permPrefix'],
$searchBar,
$hasStatus ? $this->statusTpl() : '',
implode("\n", $items),
],
$stub
);
}
/* =========================== 渲染:JS =========================== */
protected function renderJs(array $ctx): string
{
$cols = [];
foreach ($ctx['fields'] as $f) {
$col = $this->tableCol($f);
if ($col === '') {
continue; // 如 delete_at,不进列表
}
$cols[] = ' ' . $col;
}
$hasSearch = $this->searchFields($ctx['fields']) !== [];
$stub = $this->stub('js.stub');
return str_replace(
['{%title%}', '{%module%}', '{%perm%}', '{%permPrefix%}', '{%addon%}', '{%route%}', '{%var%}', '{%cols%}', '{%searchEvent%}'],
[
$ctx['title'],
$ctx['viewDir'],
$ctx['perm'],
$ctx['permPrefix'],
$ctx['addon'],
$ctx['route'],
$ctx['var'],
implode(",\n", $cols),
$hasSearch ? $this->searchEvent() : '',
],
$stub
);
}
/* ========================= 字段 → 控件映射 ========================= */
/**
* 单个字段对应的 layui 表单项 HTML
*/
protected function formItem(array $f, array $ctx): string
{
$name = $f['field'];
$label = $f['comment'] ?: $this->labelOf($name);
$type = $f['base'];
$req = $f['required'] ? ' required lay-verify="required"' : '';
$html = '';
$html .= ' <div class="layui-form-item">' . "\n";
$html .= ' <label class="layui-form-label">' . $label . '</label>' . "\n";
// 开关/状态:tinyint 且字段名含 status / is_ / enable
if ($type === 'tinyint' && (in_array($name, ['status', 'enable', 'enabled'], true) || str_starts_with($name, 'is_'))) {
$html .= ' <div class="layui-input-block">' . "\n";
$html .= ' <input type="radio" name="' . $name . '" value="1" title="是" {{ d.' . $name . '==undefined || d.' . $name . '==1 ? \'checked\' : \'\' }}>' . "\n";
$html .= ' <input type="radio" name="' . $name . '" value="0" title="否" {{ d.' . $name . '==0 ? \'checked\' : \'\' }}>' . "\n";
$html .= ' </div>' . "\n";
$html .= ' </div>';
return $html;
}
// 枚举:生成下拉
if (in_array($type, ['enum', 'set'], true) && $f['options'] !== []) {
$html .= ' <div class="layui-input-block">' . "\n";
$html .= ' <select name="' . $name . '"' . ($f['required'] ? ' lay-verify="required"' : '') . '>' . "\n";
$html .= ' <option value="">请选择</option>' . "\n";
foreach ($f['options'] as $opt) {
$html .= ' <option value="' . $opt . '" {{ d.' . $name . '==\'' . $opt . '\' ? \'selected\' : \'\' }}>' . $opt . '</option>' . "\n";
}
$html .= ' </select>' . "\n";
$html .= ' </div>' . "\n";
$html .= ' </div>';
return $html;
}
// 图片 / 文件字段
if (in_array($name, self::MEDIA_FIELDS, true)) {
$html .= ' <div class="layui-input-block">' . "\n";
$html .= ' <input type="text" name="' . $name . '"' . $req . ' placeholder="请填写地址,或接入上传组件后选择文件" autocomplete="off" class="layui-input" value="{{d.' . $name . '||\'\'}}">' . "\n";
$html .= ' </div>' . "\n";
$html .= ' </div>';
return $html;
}
// 大文本
if (in_array($type, ['text', 'tinytext', 'mediumtext', 'longtext'], true) || in_array($name, self::TEXT_FIELDS, true)) {
$html .= ' <div class="layui-input-block">' . "\n";
$html .= ' <textarea name="' . $name . '"' . ($f['required'] ? ' lay-verify="required"' : '') . ' placeholder="请输入' . $label . '" class="layui-textarea">{{d.' . $name . '||\'\'}}</textarea>' . "\n";
$html .= ' </div>' . "\n";
$html .= ' </div>';
return $html;
}
// 数字
if (in_array($type, ['int', 'bigint', 'smallint', 'mediumint', 'tinyint', 'decimal', 'float', 'double'], true)) {
$html .= ' <div class="layui-input-block">' . "\n";
$html .= ' <input type="number" name="' . $name . '"' . $req . ' placeholder="请输入' . $label . '" autocomplete="off" class="layui-input" value="{{d.' . $name . '||0}}">' . "\n";
$html .= ' </div>' . "\n";
$html .= ' </div>';
return $html;
}
// 日期时间(date/datetime 类型,非 int 时间戳)
if (in_array($type, ['date', 'datetime', 'time'], true)) {
$html .= ' <div class="layui-input-block">' . "\n";
$html .= ' <input type="text" name="' . $name . '"' . $req . ' class="layui-input" id="' . $ctx['var'] . '-' . $name . '" placeholder="请选择' . $label . '" value="{{d.' . $name . '||\'\'}}">' . "\n";
$html .= ' </div>' . "\n";
$html .= ' </div>';
return $html;
}
// 默认文本框
$html .= ' <div class="layui-input-block">' . "\n";
$html .= ' <input type="text" name="' . $name . '"' . $req . ' placeholder="请输入' . $label . '" autocomplete="off" class="layui-input" value="{{d.' . $name . '||\'\'}}">' . "\n";
$html .= ' </div>' . "\n";
$html .= ' </div>';
return $html;
}
/**
* 字段对应的 layui 表格列配置
*/
protected function tableCol(array $f): string
{
$name = $f['field'];
$title = $f['comment'] ?: $this->labelOf($name);
$type = $f['base'];
if ($name === 'status') {
return "{field: '{$name}', title: '{$title}', width: 90, templet: '#statusTpl'}";
}
if (in_array($name, ['create_at', 'update_at'], true)) {
$title = $name === 'create_at' ? '创建时间' : '更新时间';
return "{field: '{$name}', title: '{$title}', width: 170, templet: function (d) {\n"
. " return d.{$name} ? layui.util.toDateString(d.{$name} * 1000, 'yyyy-MM-dd HH:mm:ss') : '';\n"
. " }}";
}
if ($name === 'delete_at') {
return '';
}
// 大文本不直接展示,但仍需进入行数据(hide:true 保留数据供编辑回填)
if (in_array($type, ['text', 'tinytext', 'mediumtext', 'longtext'], true) || in_array($name, self::TEXT_FIELDS, true)) {
return "{field: '{$name}', title: '{$title}', hide: true}";
}
if (in_array($type, ['int', 'bigint', 'smallint', 'mediumint', 'tinyint'], true)) {
$width = in_array($name, ['sort', 'weight'], true) ? ', width: 90' : '';
return "{field: '{$name}', title: '{$title}'{$width}, sort: true}";
}
return "{field: '{$name}', title: '{$title}'}";
}
/**
* 生成 ThinkPHP Validate 规则数组文本
*/
protected function validateRules(array $ctx): string
{
$lines = [];
foreach ($this->editableFields($ctx['fields']) as $f) {
$rules = [];
if ($f['required']) {
$rules[] = 'require';
}
if (in_array($f['base'], ['int', 'bigint', 'smallint', 'mediumint', 'tinyint', 'decimal', 'float', 'double'], true)) {
$rules[] = 'number';
}
if (in_array($f['base'], ['char', 'varchar'], true) && $f['length'] > 0) {
$rules[] = 'max:' . $f['length'];
}
if (in_array($f['field'], $ctx['uniques'], true)) {
$rules[] = 'unique:' . $ctx['table'] . ',' . $f['field'];
}
if ($rules === []) {
continue;
}
$lines[] = ' \'' . $f['field'] . '|' . ($f['comment'] ?: $this->labelOf($f['field'])) . '\' => \'' . implode('|', $rules) . '\',';
}
return $lines ? "\n" . implode("\n", $lines) . "\n " : '';
}
/**
* 模型 $type 转换声明
*/
protected function modelTypes(array $fields): array
{
$types = [];
foreach ($fields as $f) {
if (in_array($f['base'], ['int', 'bigint', 'smallint', 'mediumint', 'tinyint'], true)) {
$types[$f['field']] = 'integer';
} elseif (in_array($f['base'], ['decimal', 'float', 'double'], true)) {
$types[$f['field']] = 'float';
}
}
unset($types['id']);
return $types;
}
/* ============================ 搜索相关 ============================ */
protected function searchFields(array $fields): array
{
$hit = [];
foreach ($fields as $f) {
if (in_array($f['field'], self::SEARCH_FIELDS, true) && in_array($f['base'], ['char', 'varchar'], true)) {
$hit[] = $f['field'];
}
}
return array_slice($hit, 0, 3);
}
protected function searchSnippet(array $fields): string
{
$hit = $this->searchFields($fields);
if ($hit === []) {
return '';
}
$fieldsStr = implode('|', $hit);
return "\n \$kw = trim((string) \$this->request->param('keyword', ''));\n"
. " if (\$kw !== '') {\n"
. " \$query->whereLike('{$fieldsStr}', '%' . \$kw . '%');\n"
. " }\n ";
}
protected function searchBar(array $fields): string
{
if ($this->searchFields($fields) === []) {
return '';
}
return ' <form class="layui-form" lay-filter="search-form" style="margin-bottom:10px">' . "\n"
. ' <div class="layui-inline">' . "\n"
. ' <input type="text" name="keyword" placeholder="关键字" autocomplete="off" class="layui-input" style="width:200px">' . "\n"
. ' </div>' . "\n"
. ' <button class="layui-btn" lay-submit lay-filter="search-submit">搜索</button>' . "\n"
. ' <button type="reset" class="layui-btn layui-btn-primary" id="search-reset">重置</button>' . "\n"
. ' </form>' . "\n";
}
protected function searchEvent(): string
{
return "\n // 关键字搜索\n"
. " form.on('submit(search-submit)', function (o) {\n"
. " table.reload('dataTable', { where: { keyword: o.field.keyword }, page: { curr: 1 } });\n"
. " return false;\n"
. " });\n"
. " \$('#search-reset').on('click', function () {\n"
. " setTimeout(function () { table.reload('dataTable', { where: { keyword: '' }, page: { curr: 1 } }); }, 10);\n"
. " });\n";
}
protected function statusTpl(): string
{
return '<!-- 状态列模板 -->' . "\n"
. '<script type="text/html" id="statusTpl">' . "\n"
. ' {{# if(d.status == 1) { }}' . "\n"
. ' <span class="layui-badge layui-bg-green">启用</span>' . "\n"
. ' {{# } else { }}' . "\n"
. ' <span class="layui-badge layui-bg-orange">禁用</span>' . "\n"
. ' {{# } }}' . "\n"
. '</script>' . "\n";
}
/* ========================== 路由 / 菜单 ========================== */
/**
* 向插件 route/app.php 的 backend 分组内追加路由
*
* @return bool|string true 成功,字符串为未写入原因
*/
protected function appendRoute(string $file, array $ctx)
{
if (!is_file($file)) {
return "路由文件不存在,已跳过:" . $this->short($file);
}
$content = (string) file_get_contents($file);
if (strpos($content, "'{$ctx['route']}/index'") !== false) {
return "路由已存在,未重复写入";
}
$block = " Route::get('{$ctx['route']}/index', '{$ctx['class']}/index');\n"
. " Route::post('{$ctx['route']}/add', '{$ctx['class']}/add');\n"
. " Route::post('{$ctx['route']}/update', '{$ctx['class']}/update');\n"
. " Route::post('{$ctx['route']}/delete', '{$ctx['class']}/delete');\n";
// 核心后台 route/app.php 是扁平结构(无 backend 分组),直接追加到文件末尾
if (($ctx['mode'] ?? 'addon') === 'backend') {
$new = rtrim($content) . "\n\n// {$ctx['title']}(addon:crud 生成)\n" . $block;
return $this->writeWithLint($file, $new, $content);
}
// 定位 Route::group('backend' ...) 块,在其闭合括号前插入
if (preg_match('/Route::group\(\s*[\'"]backend[\'"]/', $content, $m, PREG_OFFSET_CAPTURE)) {
$start = (int) $m[0][1]; // PREG_OFFSET_CAPTURE 给出字节偏移
$open = strpos($content, '(', $start);
$depth = 0;
$end = null;
for ($i = $open; $i < strlen($content); $i++) {
if ($content[$i] === '(') {
$depth++;
} elseif ($content[$i] === ')') {
$depth--;
if ($depth === 0) {
$end = $i;
break;
}
}
}
if ($end !== null) {
// group 的闭合括号是 group('backend', function () { ... }) 最外层那个 ')';
// 代码块必须插在闭包体 '}' 之前——插在 '}' 与 ')' 之间会破坏语法。
$brace = strrpos(substr($content, 0, $end), '}');
if ($brace !== false) {
$new = substr($content, 0, $brace) . $block . substr($content, $brace);
return $this->writeWithLint($file, $new, $content);
}
}
}
// 没有 backend 分组:追加一个完整分组
$new = rtrim($content) . "\n\n"
. "Route::group('backend', function () {\n"
. $block
. "})->layer('backend');\n";
return $this->writeWithLint($file, $new, $content);
}
/**
* 写入 PHP 文件并做语法校验,失败则回滚。
* 路由文件一旦写坏,整个 think 命令行都会启动失败,故必须兜底。
*
* @return bool|string
*/
protected function writeWithLint(string $file, string $new, string $original)
{
// 沿用文件原有的换行符,避免 CRLF / LF 混用
$eol = strpos($original, "\r\n") !== false ? "\r\n" : "\n";
$new = str_replace(["\r\n", "\n"], ["\n", $eol], $new);
file_put_contents($file, $new);
if ($this->lint($file)) {
return true;
}
file_put_contents($file, $original);
return "写入后语法检查未通过,已回滚:" . $this->short($file);
}
/**
* 语法检查
*
* 环境禁用了 exec/shell_exec,无法调用 `php -l`;改用 token_get_all() 的
* TOKEN_PARSE 标志——它会对代码做完整语法解析,出错时抛 ParseError。
*/
protected function lint(string $file): bool
{
$code = (string) file_get_contents($file);
try {
token_get_all($code, TOKEN_PARSE);
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* 向插件 menu.json 追加菜单项与按钮权限节点
*
* @return bool|string
*/
protected function appendMenu(string $file, array $ctx)
{
$menu = is_file($file) ? json_decode((string) file_get_contents($file), true) : null;
if (!is_array($menu)) {
$menu = ['frontend' => [], 'member' => [], 'backend' => []];
}
if (!isset($menu['backend']) || !is_array($menu['backend'])) {
$menu['backend'] = [];
}
$name = $ctx['route'] . '/index';
foreach ($menu['backend'] as $item) {
if (($item['name'] ?? '') === $name) {
return "菜单项 {$name} 已存在,未重复写入";
}
}
$menu['backend'][] = [
'name' => $name,
'title' => $ctx['title'],
'icon' => $ctx['icon'],
'type' => 2,
'sort' => 100 + count($menu['backend']) * 10,
'route' => $ctx['addon'] . '/backend/' . $ctx['route'] . '/index',
'child' => [
['name' => $ctx['route'] . ':add', 'title' => '新增', 'type' => 4],
['name' => $ctx['route'] . ':update', 'title' => '编辑', 'type' => 4],
['name' => $ctx['route'] . ':delete', 'title' => '删除', 'type' => 4],
],
];
file_put_contents(
$file,
json_encode($menu, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
return true;
}
/**
* 核心后台:把菜单与按钮权限直接写入 backend_power 表
*
* 核心后台没有 menu.json,菜单即权限记录(type:1目录 / 2菜单 / 3按钮),
* 权限名格式为 controller:action,与 Auth::verifyAuth 的精确匹配一致。
*
* @return bool|string
*/
protected function appendPower(array $ctx, int $pid)
{
$name = $ctx['perm'] . ':index';
try {
$exist = Db::name('backend_power')->where('name', $name)->find();
} catch (\Throwable $e) {
return '权限表不可用,已跳过(' . $e->getMessage() . ')';
}
if ($exist) {
return "权限节点 {$name} 已存在,未重复写入";
}
$now = time();
try {
$menuId = Db::name('backend_power')->insertGetId([
'pid' => max(0, $pid),
'title' => $ctx['title'],
'name' => $name,
'type' => 2,
'route' => $ctx['route'] . '/index',
'icon' => $ctx['icon'],
'sort' => 100,
'addon' => null,
'status' => 1,
'create_at' => $now,
'update_at' => $now,
'delete_at' => 0,
]);
foreach (['add' => '新增', 'update' => '编辑', 'delete' => '删除'] as $act => $label) {
Db::name('backend_power')->insert([
'pid' => $menuId,
'title' => $label,
'name' => $ctx['perm'] . ':' . $act,
'type' => 3,
'route' => $ctx['route'] . '/' . $act,
'icon' => '',
'sort' => 1,
'addon' => null,
'status' => 1,
'create_at' => $now,
'update_at' => $now,
'delete_at' => 0,
]);
}
} catch (\Throwable $e) {
return '写入权限节点失败:' . $e->getMessage();
}
return true;
}
/* ============================ 表结构解析 ============================ */
protected function normalizeColumns(array $columns): array
{
$fields = [];
foreach ($columns as $col) {
$col = array_change_key_case($col, CASE_LOWER);
$type = $col['type'] ?? 'varchar(255)';
[$base, $length, $options] = $this->parseType($type);
$fields[] = [
'field' => $col['field'] ?? '',
'type' => $type,
'base' => $base,
'length' => $length,
'options' => $options,
'null' => strtoupper((string) ($col['null'] ?? '')) === 'YES',
'key' => (string) ($col['key'] ?? ''),
'default' => $col['default'] ?? null,
'extra' => strtolower((string) ($col['extra'] ?? '')),
'comment' => trim((string) ($col['comment'] ?? '')),
'required' => false,
];
}
// 必填判定:NOT NULL + 无默认值 + 非自增 + 非时间戳 + 非主键
foreach ($fields as $i => $f) {
$fields[$i]['required'] = !$f['null']
&& $f['default'] === null
&& $f['extra'] !== 'auto_increment'
&& $f['key'] !== 'PRI'
&& !in_array($f['field'], self::TIME_FIELDS, true);
}
return $fields;
}
/**
* 解析字段类型:varchar(50) → ['varchar', 50, []];enum('a','b') → ['enum', 0, ['a','b']]
*/
protected function parseType(string $type): array
{
$type = strtolower(trim($type));
$base = $type;
$len = 0;
$opts = [];
if (preg_match('/^([a-z]+)\s*(\((.*)\))?/', $type, $m)) {
$base = $m[1];
if (isset($m[3]) && $m[3] !== '') {
$inner = $m[3];
if (in_array($base, ['enum', 'set'], true)) {
$opts = array_map(fn($v) => trim($v, " '\""), explode(',', $inner));
} elseif (strpos($inner, ',') !== false) {
$len = (int) trim(explode(',', $inner)[0]);
} else {
$len = (int) $inner;
}
}
}
return [$base, $len, $opts];
}
protected function uniqueFields(string $table): array
{
try {
$indexes = Db::query('SHOW INDEX FROM `' . $table . '`');
} catch (\Throwable $e) {
return [];
}
$single = [];
$multi = [];
foreach ($indexes as $idx) {
$idx = array_change_key_case($idx, CASE_LOWER);
if ((int) ($idx['non_unique'] ?? 1) !== 0 || ($idx['key_name'] ?? '') === 'PRIMARY') {
continue;
}
$single[$idx['key_name']][] = $idx['column_name'] ?? '';
}
foreach ($single as $cols) {
if (count($cols) === 1) {
$multi[] = $cols[0];
}
}
return $multi;
}
protected function tableComment(string $table): string
{
try {
$row = Db::query('SHOW TABLE STATUS WHERE `Name` = \'' . $table . '\'');
} catch (\Throwable $e) {
return '';
}
if (empty($row)) {
return '';
}
$row = array_change_key_case($row[0], CASE_LOWER);
return trim((string) ($row['comment'] ?? ''));
}
protected function primaryKey(array $fields): string
{
foreach ($fields as $f) {
if ($f['key'] === 'PRI') {
return $f['field'];
}
}
return 'id';
}
/**
* 可编辑字段:排除主键与时间戳字段
*/
protected function editableFields(array $fields): array
{
$out = [];
foreach ($fields as $f) {
if ($f['key'] === 'PRI' || in_array($f['field'], self::TIME_FIELDS, true)) {
continue;
}
$out[] = $f;
}
return $out;
}
protected function tablePrefix(): string
{
$prefix = (string) \think\facade\Config::get('database.connections.mysql.prefix', '');
if ($prefix === '') {
$prefix = (string) Db::getConfig('prefix');
}
return $prefix;
}
/* ============================== 工具 ============================== */
protected function stub(string $name): string
{
return (string) file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'crud' . DIRECTORY_SEPARATOR . $name);
}
/**
* 收敛占位符为空时产生的连续空行
*/
protected function clean(string $content): string
{
return (string) preg_replace("/\n{3,}/", "\n\n", $content);
}
protected function short(string $path): string
{
return str_replace($this->app->getRootPath(), '', $path);
}
protected function studly(string $value): string
{
return str_replace(' ', '', ucwords(str_replace(['_', '-'], ' ', $value)));
}
protected function camel(string $value): string
{
return lcfirst($this->studly($value));
}
protected function snake(string $value): string
{
return strtolower((string) preg_replace('/(?<!^)[A-Z]/', '_$0', $value));
}
/**
* 字段名 → 中文标签(仅在表无注释时的兜底)
*/
protected function labelOf(string $field): string
{
$map = [
'name' => '名称', 'title' => '标题', 'status' => '状态', 'sort' => '排序',
'remark' => '备注', 'content' => '内容', 'description' => '描述', 'intro' => '简介',
'cover' => '封面', 'image' => '图片', 'avatar' => '头像', 'url' => '链接',
'type' => '类型', 'pid' => '上级', 'weight' => '权重', 'mobile' => '手机号',
'email' => '邮箱', 'username' => '用户名', 'alias' => '别名', 'price' => '价格',
];
return $map[$field] ?? ucfirst(str_replace('_', ' ', $field));
}
}