ExtensionScaffoldMakeCommand.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. <?php
  2. declare(strict_types=1);
  3. namespace SixShop\System\Command;
  4. use function SixShop\Core\extension_path;
  5. use think\console\Command;
  6. use think\console\Input;
  7. use think\console\input\Argument;
  8. use think\console\input\Option;
  9. use think\console\Output;
  10. class ExtensionScaffoldMakeCommand extends Command
  11. {
  12. protected function configure(): void
  13. {
  14. $this->setName('extension:make')
  15. ->setDescription('生成扩展脚手架骨架(后端+前端,可选 Service/Entity/FFI/Frontend)')
  16. ->addArgument('module', Argument::REQUIRED, '扩展模块名(目录名,建议小写下划线)')
  17. ->addOption('with-api', null, Option::VALUE_NONE, '生成 API 路由与控制器')
  18. ->addOption('with-admin', null, Option::VALUE_NONE, '生成 Admin 路由与控制器')
  19. ->addOption('with-service', null, Option::VALUE_NONE, '生成 Service 层')
  20. ->addOption('with-entity', null, Option::VALUE_NONE, '生成 Entity 层')
  21. ->addOption('with-migration', null, Option::VALUE_NONE, '生成迁移与安装/卸载 SQL 样板')
  22. ->addOption('with-frontend', null, Option::VALUE_NONE, '生成前端 Admin 模板')
  23. ->addOption('with-ffi', null, Option::VALUE_NONE, '生成 FFI 目录与构建脚本样板')
  24. ->addOption('adapter', null, Option::VALUE_REQUIRED, '服务适配默认实现 php|ffi|auto', 'php')
  25. ->addOption('desc', null, Option::VALUE_REQUIRED, 'info.php 描述', '')
  26. ->addOption('author', null, Option::VALUE_REQUIRED, '作者', 'yourname')
  27. ->addOption('dry-run', null, Option::VALUE_NONE, '仅预览将要创建的目录与文件,不实际写入')
  28. ->addOption('force', null, Option::VALUE_NONE, '允许在已存在的模块目录内覆盖写入文件');
  29. }
  30. protected function execute(Input $input, Output $output): int
  31. {
  32. $module = (string)$input->getArgument('module');
  33. $withApi = (bool)$input->getOption('with-api');
  34. $withAdmin = (bool)$input->getOption('with-admin');
  35. $withService = (bool)$input->getOption('with-service');
  36. $withEntity = (bool)$input->getOption('with-entity');
  37. $withMigration = (bool)$input->getOption('with-migration');
  38. $withFrontend = (bool)$input->getOption('with-frontend');
  39. $withFFI = (bool)$input->getOption('with-ffi');
  40. $adapter = (string)$input->getOption('adapter');
  41. $desc = (string)$input->getOption('desc');
  42. $author = (string)$input->getOption('author');
  43. $dryRun = (bool)$input->getOption('dry-run');
  44. $force = (bool)$input->getOption('force');
  45. if (!$module) {
  46. $output->error('模块名不能为空');
  47. return 1;
  48. }
  49. // 默认行为:如果用户未显式指定任何 with-* 选项,则默认生成“完整插件”(除 FFI)
  50. $anySpecified = $withApi || $withAdmin || $withService || $withEntity || $withMigration || $withFrontend || $withFFI;
  51. if (!$anySpecified) {
  52. $withApi = $withAdmin = $withService = $withEntity = $withMigration = $withFrontend = true;
  53. // $withFFI 默认为 false,避免环境未开启 FFI 导致构建失败
  54. }
  55. $base = rtrim(extension_path($module), '/');
  56. if (is_dir($base) && !$force) {
  57. $output->error("扩展 {$module} 已存在:{$base},可使用 --force 覆盖写入");
  58. return 1;
  59. }
  60. // 目录结构
  61. $dirs = [
  62. "$base/src/Controller/Api",
  63. "$base/src/Controller/Admin",
  64. "$base/src/Service",
  65. "$base/src/Entity",
  66. "$base/src/Hook",
  67. "$base/database/migrations",
  68. "$base/database/seeds",
  69. "$base/route",
  70. "$base/config",
  71. ];
  72. // 计划文件(用于 dry-run 展示)
  73. $ns = "SixShop\\\\Extension\\\\{$module}";
  74. $studly = str_replace(['-', '_'], '', ucwords($module, '-_'));
  75. $planFiles = [
  76. "$base/info.php",
  77. "$base/config.php",
  78. "$base/README.md",
  79. "$base/src/Extension.php",
  80. "$base/src/Hook/{$studly}Hook.php",
  81. ];
  82. if ($withApi) {
  83. $planFiles[] = "$base/route/api.php";
  84. $planFiles[] = "$base/src/Controller/Api/HelloController.php";
  85. $planFiles[] = "$base/src/Controller/Api/ItemController.php";
  86. }
  87. if ($withAdmin) {
  88. $planFiles[] = "$base/route/admin.php";
  89. $planFiles[] = "$base/src/Controller/Admin/DashboardController.php";
  90. $planFiles[] = "$base/src/Controller/Admin/ManageController.php";
  91. $planFiles[] = "$base/src/Controller/Admin/ItemController.php";
  92. $planFiles[] = "$base/src/Controller/Admin/UploadController.php";
  93. }
  94. if ($withService) $planFiles[] = "$base/src/Service/{$studly}Service.php";
  95. if ($withEntity) $planFiles[] = "$base/src/Entity/{$studly}.php";
  96. if ($withMigration) {
  97. $planFiles[] = "$base/config/install.sql";
  98. $planFiles[] = "$base/config/uninstall.sql";
  99. }
  100. if ($dryRun) {
  101. $output->writeln("[DRY-RUN] 将创建以下目录:");
  102. foreach ($dirs as $d) {
  103. $output->writeln(" - $d");
  104. }
  105. $output->writeln("[DRY-RUN] 将创建以下关键文件(部分):");
  106. foreach ($planFiles as $f) {
  107. $output->writeln(" - $f");
  108. }
  109. return 0;
  110. }
  111. foreach ($dirs as $d) @mkdir($d, 0777, true);
  112. // info.php
  113. // 生成完整 info.php(参考 guimi)
  114. $info = [
  115. 'id' => $module,
  116. 'name' => $module,
  117. // 分类:core|content|shop|other|custom,默认 custom
  118. 'category' => 'custom',
  119. 'description' => $desc ?: ($module . ' 扩展模块'),
  120. 'version' => '0.1.0',
  121. 'core_version' => '^1.0',
  122. 'author' => $author ?: 'sixshop',
  123. 'email' => '',
  124. 'website' => '',
  125. 'image' => '',
  126. 'license' => 'MIT',
  127. 'keywords' => [],
  128. 'dependencies' => [],
  129. 'conflicts' => [],
  130. 'requires' => [
  131. 'php' => '>=8.0.0',
  132. 'extensions' => ['json', 'pdo'],
  133. ],
  134. ];
  135. $infoExport = var_export($info, true);
  136. $infoExport = str_replace(['array (', ')'], ['[', ']'], $infoExport);
  137. file_put_contents("$base/info.php", "<?php\ndeclare(strict_types=1);\n\nreturn " . $infoExport . ";\n");
  138. // Extension.php(使用 Nowdoc + sprintf 注入命名空间)
  139. $ns = 'SixShop\\Extension\\' . $module;
  140. $extClass = sprintf(<<<'PHP'
  141. <?php
  142. declare(strict_types=1);
  143. namespace %s;
  144. use SixShop\Core\ExtensionAbstract;
  145. use think\facade\Db;
  146. class Extension extends ExtensionAbstract
  147. {
  148. protected function getBaseDir(): string
  149. {
  150. return dirname(__DIR__);
  151. }
  152. public function install(): void
  153. {
  154. $sqlFile = __DIR__ . '/../config/install.sql';
  155. if (is_file($sqlFile)) {
  156. $sql = file_get_contents($sqlFile);
  157. if ($sql) { Db::execute($sql); }
  158. }
  159. }
  160. public function uninstall(): void
  161. {
  162. $sqlFile = __DIR__ . '/../config/uninstall.sql';
  163. if (is_file($sqlFile)) {
  164. $sql = file_get_contents($sqlFile);
  165. if ($sql) { Db::execute($sql); }
  166. }
  167. }
  168. }
  169. PHP, $ns);
  170. file_put_contents("$base/src/Extension.php", $extClass);
  171. // config.php(form-create 占位)
  172. $configPhp = <<<PHP
  173. <?php
  174. declare(strict_types=1);
  175. return [
  176. 'form' => [
  177. [
  178. 'type' => 'input',
  179. 'field' => 'title',
  180. 'title' => '标题',
  181. 'value' => '',
  182. 'props' => ['placeholder' => '请输入标题'],
  183. ],
  184. ],
  185. ];
  186. PHP;
  187. file_put_contents("$base/config.php", $configPhp);
  188. // README
  189. file_put_contents("$base/README.md", "# {$module}\n\n自动生成的扩展骨架。\n");
  190. // 上面已生成 Extension.php,这里不再重复生成
  191. // Hook 占位(下面统一生成一次)
  192. // 安装/卸载 SQL 样板
  193. if ($withMigration) {
  194. $install = "-- 安装 SQL 示例\n";
  195. $uninstall = "-- 卸载 SQL 示例\n";
  196. file_put_contents("$base/config/install.sql", $install);
  197. file_put_contents("$base/config/uninstall.sql", $uninstall);
  198. }
  199. // 路由(注意:系统会自动加 /api/{$module} 或 /admin/{$module} 前缀,这里不需要再包一层模块分组)
  200. $apiRoute = sprintf(<<<'PHP'
  201. <?php
  202. declare(strict_types=1);
  203. use SixShop\Extension\%s\Controller\Api\ItemController;
  204. use think\facade\Route;
  205. // 注意:前缀由系统自动添加,这里只写相对路径
  206. // 健康检查
  207. Route::get('ping', fn() => json(['code' => 0, 'msg' => 'ok', 'data' => ['pong' => true]]))->middleware(['auth']);
  208. // 示例:业务分组-具体动作(放在资源路由之前,避免 :id 冲突)
  209. Route::group('item', function () {
  210. Route::get('info', [ItemController::class, 'info']);
  211. Route::post('check', [ItemController::class, 'check']);
  212. })->middleware(['auth']);
  213. PHP, $module);
  214. $adminRoute = sprintf(<<<'PHP'
  215. <?php
  216. declare(strict_types=1);
  217. use think\facade\Route;
  218. use SixShop\Extension\%s\Controller\Admin\DashboardController;
  219. use SixShop\Extension\%s\Controller\Admin\ItemController;
  220. use SixShop\Extension\%s\Controller\Admin\UploadController;
  221. // 首页/仪表盘控制器路由(对齐 guimi 写法)
  222. Route::get('dashboard/stats', [DashboardController::class, 'stats'])->middleware(['auth']);
  223. // 可按需继续追加:relation-trend / verification-trend / redemption-trend / latest 等
  224. // 通用上传
  225. Route::post('upload', [UploadController::class, 'handle'])->middleware(['auth']);
  226. PHP, $module, $module, $module);
  227. if ($withApi) file_put_contents("$base/route/api.php", $apiRoute);
  228. if ($withAdmin) file_put_contents("$base/route/admin.php", $adminRoute);
  229. // 控制器样板
  230. if ($withApi) {
  231. $apiCtrl = sprintf(<<<'PHP'
  232. <?php
  233. declare(strict_types=1);
  234. namespace %s\Controller\Api;
  235. use think\Request; use think\Response;
  236. class HelloController
  237. {
  238. private function uid(Request $r): ?int { return $r->userID ?? ($r->adminID ?? null); }
  239. public function index(Request $r): Response
  240. {
  241. if (!$this->uid($r)) return json(['code'=>401,'msg'=>'未登录']);
  242. return json(['code'=>0,'msg'=>'ok','data'=>['now'=>date('c')]]);
  243. }
  244. }
  245. PHP, $ns);
  246. file_put_contents("$base/src/Controller/Api/HelloController.php", $apiCtrl);
  247. // API 资源控制器
  248. $apiItemCtrl = sprintf(<<<'PHP'
  249. <?php
  250. declare(strict_types=1);
  251. namespace %s\Controller\Api;
  252. use think\Request; use think\Response;
  253. class ItemController
  254. {
  255. private function uid(Request $r): ?int { return $r->userID ?? ($r->adminID ?? null); }
  256. public function index(Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>['list'=>[], 'total'=>0]]); }
  257. public function read(int $id): Response { return json(['code'=>0,'msg'=>'ok','data'=>['id'=>$id]]); }
  258. public function save(Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
  259. public function update(int $id, Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
  260. public function delete(int $id): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
  261. // 具体动作示例(与路由匹配)
  262. public function info(Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>['info'=>[]]]); }
  263. public function check(Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
  264. }
  265. PHP, $ns);
  266. file_put_contents("$base/src/Controller/Api/ItemController.php", $apiItemCtrl);
  267. }
  268. if ($withAdmin) {
  269. $adminCtrl = sprintf(<<<'PHP'
  270. <?php
  271. declare(strict_types=1);
  272. namespace %s\Controller\Admin;
  273. use think\Request; use think\Response;
  274. class ManageController
  275. {
  276. public function list(Request $r): Response
  277. {
  278. return json(['code'=>0,'msg'=>'ok','data'=>['list'=>[], 'total'=>0]]);
  279. }
  280. }
  281. PHP, $ns);
  282. file_put_contents("$base/src/Controller/Admin/ManageController.php", $adminCtrl);
  283. // 首页/仪表盘控制器(对齐 guimi:dashboard/*)
  284. $dashboardCtrl = sprintf(<<<'PHP'
  285. <?php
  286. declare(strict_types=1);
  287. namespace %s\Controller\Admin;
  288. use think\Response;
  289. class DashboardController
  290. {
  291. public function stats(): Response
  292. {
  293. // 首页统计占位:可返回卡片统计与趋势入口
  294. return json(['code' => 0, 'msg' => 'ok', 'data' => [
  295. 'cards' => [
  296. ['title' => '总数', 'value' => 0],
  297. ],
  298. ]]);
  299. }
  300. }
  301. PHP, $ns);
  302. file_put_contents("$base/src/Controller/Admin/DashboardController.php", $dashboardCtrl);
  303. // Admin 资源控制器
  304. $adminItemCtrl = sprintf(<<<'PHP'
  305. <?php
  306. declare(strict_types=1);
  307. namespace %s\Controller\Admin;
  308. use think\Request; use think\Response;
  309. class ItemController
  310. {
  311. public function index(Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>['list'=>[], 'total'=>0]]); }
  312. public function read(int $id): Response { return json(['code'=>0,'msg'=>'ok','data'=>['id'=>$id]]); }
  313. public function save(Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
  314. public function update(int $id, Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
  315. public function delete(int $id): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
  316. }
  317. PHP, $ns);
  318. file_put_contents("$base/src/Controller/Admin/ItemController.php", $adminItemCtrl);
  319. // Admin 上传控制器
  320. $uploadCtrl = sprintf(<<<'PHP'
  321. <?php
  322. declare(strict_types=1);
  323. namespace %s\Controller\Admin;
  324. use think\Request; use think\Response;
  325. class UploadController
  326. {
  327. public function handle(Request $r): Response
  328. {
  329. // TODO: 接入实际存储逻辑,返回 { url, name }
  330. return json(['code'=>0,'msg'=>'ok','data'=>['url'=>'','name'=>'']]);
  331. }
  332. }
  333. PHP, $ns);
  334. file_put_contents("$base/src/Controller/Admin/UploadController.php", $uploadCtrl);
  335. }
  336. // Service / Entity 占位
  337. if ($withService) {
  338. $svc = sprintf(<<<'PHP'
  339. <?php
  340. declare(strict_types=1);
  341. namespace %s\Service;
  342. class %sService
  343. {
  344. public function ping(): array { return ['pong' => true]; }
  345. }
  346. PHP, $ns, $studly);
  347. file_put_contents("$base/src/Service/{$studly}Service.php", $svc);
  348. }
  349. if ($withEntity) {
  350. $ent = sprintf(<<<'PHP'
  351. <?php
  352. declare(strict_types=1);
  353. namespace %s\Entity;
  354. class %sEntity
  355. {
  356. public const TABLE = 'extension_%s_item';
  357. }
  358. PHP, $ns, $studly, $module);
  359. file_put_contents("$base/src/Entity/{$studly}Entity.php", $ent);
  360. }
  361. // 迁移 & 安装/卸载 SQL
  362. if ($withMigration) {
  363. $install = "-- 安装 SQL 示例\n" .
  364. "CREATE TABLE IF NOT EXISTS `extension_{$module}_item`(\n" .
  365. " `id` int unsigned NOT NULL AUTO_INCREMENT,\n" .
  366. " `title` varchar(255) NOT NULL DEFAULT '',\n" .
  367. " `created_at` int unsigned NOT NULL DEFAULT 0,\n" .
  368. " PRIMARY KEY (`id`)\n" .
  369. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n";
  370. $uninstall = "DROP TABLE IF EXISTS `extension_{$module}_item`;\n";
  371. file_put_contents("$base/config/install.sql", $install);
  372. file_put_contents("$base/config/uninstall.sql", $uninstall);
  373. }
  374. // Hook 示例(统一在此生成一次)
  375. $hook = sprintf(<<<'PHP'
  376. <?php
  377. declare(strict_types=1);
  378. namespace %s\Hook;
  379. class %sHook
  380. {
  381. /** 示例:用户登录后 */
  382. public function onUserLogin(array $payload): void {}
  383. }
  384. PHP, $ns, $studly);
  385. file_put_contents("$base/src/Hook/{$studly}Hook.php", $hook);
  386. // FFI 样板
  387. if ($withFFI) {
  388. @mkdir("$base/ffi/model", 0777, true);
  389. $gomod = sprintf("module %s\n\ngo 1.21\n", $module);
  390. file_put_contents("$base/ffi/go.mod", $gomod);
  391. $mainGo = <<<'GO'
  392. package main
  393. // TODO: 实现导出方法
  394. func main() {}
  395. GO;
  396. file_put_contents("$base/ffi/main.go", $mainGo);
  397. $mk = sprintf(<<<'MK'
  398. .PHONY: build
  399. build:
  400. go build -buildmode=c-shared -o lib_%s.so main.go
  401. MK, $module);
  402. file_put_contents("$base/ffi/Makefile", $mk);
  403. $buildSh = <<<'SH'
  404. #!/usr/bin/env bash
  405. set -euo pipefail
  406. cd "$(dirname "$0")/ffi"
  407. make build
  408. cd ..
  409. echo "[提示] 如使用 FFI,请重启 PHP-FPM 并在 Service Adapter 中切换实现"
  410. SH;
  411. file_put_contents("$base/build.sh", $buildSh);
  412. @chmod("$base/build.sh", 0755);
  413. }
  414. // 前端 Admin 模板
  415. if ($withFrontend) {
  416. // 注意:root_path() 指向 backend/ 应用根;我们需要仓库根目录
  417. $projectRoot = rtrim(dirname(root_path()), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
  418. // 视图目录名使用连字符(kebab-case),避免下划线
  419. $feViewName = str_replace('_', '-', $module);
  420. $feBase = $projectRoot . 'frontend/admin/src/views/' . $feViewName;
  421. @mkdir($feBase . '/components', 0777, true);
  422. @mkdir($feBase . '/composables', 0777, true);
  423. $indexVue = sprintf(<<<'VUE'
  424. <template>
  425. <div class="%s-page">
  426. <a-tabs v-model:activeKey="tab">
  427. <a-tab-pane key="dashboard" tab="仪表盘" />
  428. <a-tab-pane key="list" tab="列表" />
  429. <a-tab-pane key="settings" tab="设置" />
  430. </a-tabs>
  431. <component :is="currentComp" />
  432. </div>
  433. </template>
  434. <script setup lang="ts">
  435. import { ref, computed } from 'vue'
  436. const tab = ref('dashboard')
  437. const currentComp = computed(() => {
  438. return tab.value === 'list' ? 'ListPanel' : (tab.value === 'settings' ? 'SettingsPanel' : 'DashboardPanel')
  439. })
  440. </script>
  441. VUE, $feViewName);
  442. file_put_contents($feBase . '/index.vue', $indexVue);
  443. $dash = "<template><div>DashboardPanel - {$feViewName}</div></template>\n";
  444. $list = "<template><div>ListPanel - {$feViewName}</div></template>\n";
  445. $settings = "<template><div>SettingsPanel - {$feViewName}</div></template>\n";
  446. file_put_contents($feBase . '/components/DashboardPanel.vue', $dash);
  447. file_put_contents($feBase . '/components/ListPanel.vue', $list);
  448. file_put_contents($feBase . '/components/SettingsPanel.vue', $settings);
  449. $useApi = sprintf(<<<'TS'
  450. import request from '@/utils/request'
  451. export function apiGet(url: string, params?: any) { return request.get(url, { params }) }
  452. export function apiPost(url: string, data?: any) { return request.post(url, data) }
  453. export const %sApi = {
  454. ping: () => apiGet(`/api/%s/ping`),
  455. }
  456. TS, $module, $module);
  457. file_put_contents($feBase . '/composables/useApi.ts', $useApi);
  458. }
  459. // 提示
  460. $output->writeln("<info>扩展骨架已生成:</info> $base");
  461. if ($withFrontend) $output->writeln("<comment>前端模板:</comment> frontend/admin/src/views/{$module}");
  462. $output->writeln("<comment>下一步:</comment> 1) 根据业务完善 Service/Entity 2) 配置路由与菜单 3) 如需 FFI 执行 {$module}/build.sh");
  463. return 0;
  464. }
  465. private function studly(string $value): string
  466. {
  467. $value = str_replace(['-', '_'], ' ', $value);
  468. $value = ucwords($value);
  469. return str_replace(' ', '', $value);
  470. }
  471. }