FrontendDeployService.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. <?php
  2. declare(strict_types=1);
  3. namespace SixShop\System\Service;
  4. use RecursiveDirectoryIterator;
  5. use RecursiveIteratorIterator;
  6. use RuntimeException;
  7. use function SixShop\Core\extension_path;
  8. use SixShop\Core\Service\CoreService;
  9. use think\facade\Log;
  10. /**
  11. * 前端代码部署服务
  12. *
  13. * 负责插件前端代码的自动部署和清理
  14. */
  15. class FrontendDeployService
  16. {
  17. /**
  18. * 部署前端代码
  19. *
  20. * @param string $extensionId 插件ID
  21. * @return bool 是否部署成功
  22. */
  23. public function deploy(string $extensionId): bool
  24. {
  25. try {
  26. // 1. 检查前端代码是否存在
  27. if (!$this->hasFrontendCode($extensionId)) {
  28. Log::info("插件 {$extensionId} 没有前端代码,跳过部署");
  29. return true; // 跳过,不算失败
  30. }
  31. // 2. 获取源目录和目标目录
  32. $sourcePath = $this->getFrontendSourcePath($extensionId);
  33. $targetPath = $this->getFrontendTargetPath();
  34. // 3. 检查目标目录是否已存在
  35. $pluginTargetPath = $targetPath . '/' . $extensionId;
  36. if (is_dir($pluginTargetPath)) {
  37. Log::warning("目标目录已存在,将覆盖:{$pluginTargetPath}");
  38. $this->removeDirectory($pluginTargetPath);
  39. }
  40. // 4. 复制前端代码
  41. $this->copyDirectory($sourcePath, $targetPath);
  42. // 5. 记录部署信息
  43. $this->logDeploy($extensionId, [
  44. 'source_path' => $sourcePath,
  45. 'target_path' => $targetPath,
  46. 'status' => 'success'
  47. ]);
  48. Log::info("前端代码部署成功:{$extensionId}");
  49. return true;
  50. } catch (\Exception $e) {
  51. Log::error("前端代码部署失败:{$extensionId},错误:" . $e->getMessage());
  52. // 回滚:删除已复制的文件
  53. if (isset($pluginTargetPath) && is_dir($pluginTargetPath)) {
  54. $this->removeDirectory($pluginTargetPath);
  55. }
  56. // 不抛出异常,允许安装继续
  57. return false;
  58. }
  59. }
  60. /**
  61. * 移除前端代码
  62. *
  63. * @param string $extensionId 插件ID
  64. * @return bool 是否移除成功
  65. */
  66. public function remove(string $extensionId): bool
  67. {
  68. try {
  69. // 兼容性处理:检查插件是否有前端代码
  70. // 如果插件没有前端代码(旧项目),则不删除 admin 的前端代码
  71. if (!$this->hasFrontendCode($extensionId)) {
  72. Log::info("插件 {$extensionId} 没有前端代码,跳过删除(兼容旧项目)");
  73. return true;
  74. }
  75. $targetPath = $this->getFrontendTargetPath();
  76. $pluginTargetPath = $targetPath . '/' . $extensionId;
  77. if (!is_dir($pluginTargetPath)) {
  78. Log::info("前端代码不存在,无需删除:{$extensionId}");
  79. return true;
  80. }
  81. // 删除目录
  82. $this->removeDirectory($pluginTargetPath);
  83. // 记录删除信息
  84. $this->logRemove($extensionId);
  85. Log::info("前端代码删除成功:{$extensionId}");
  86. return true;
  87. } catch (\Exception $e) {
  88. Log::error("前端代码删除失败:{$extensionId},错误:" . $e->getMessage());
  89. return false;
  90. }
  91. }
  92. /**
  93. * 检查插件是否有前端代码
  94. *
  95. * @param string $extensionId 插件ID
  96. * @return bool
  97. */
  98. private function hasFrontendCode(string $extensionId): bool
  99. {
  100. try {
  101. $frontendPath = $this->getFrontendSourcePath($extensionId);
  102. } catch (\Exception $e) {
  103. Log::warning("获取插件路径失败:{$extensionId},错误:" . $e->getMessage());
  104. return false;
  105. }
  106. // 检查目录是否存在
  107. if (!is_dir($frontendPath)) {
  108. return false;
  109. }
  110. // 检查是否有插件名称的子目录
  111. $pluginFrontendPath = $frontendPath . '/' . $extensionId;
  112. if (!is_dir($pluginFrontendPath)) {
  113. Log::warning("插件 {$extensionId} 前端代码目录结构不正确,应为:frontend/{$extensionId}/");
  114. return false;
  115. }
  116. return true;
  117. }
  118. /**
  119. * 获取插件的实际路径
  120. *
  121. * @param string $extensionId 插件ID
  122. * @return string
  123. * @throws RuntimeException
  124. */
  125. private function getPluginPath(string $extensionId): string
  126. {
  127. // 方法 1: 使用 extension_path()
  128. $path = extension_path($extensionId);
  129. if (is_dir($path)) {
  130. return $path;
  131. }
  132. // 方法 2: 从 CoreService 获取 Composer 映射
  133. if (isset(CoreService::$extensionComposerMap[$extensionId])) {
  134. $composerInfo = CoreService::$extensionComposerMap[$extensionId];
  135. $vendorPath = base_path('vendor/' . $composerInfo['name']);
  136. if (is_dir($vendorPath)) {
  137. return $vendorPath;
  138. }
  139. }
  140. // 方法 3: 手动检测常见路径
  141. $possiblePaths = [
  142. base_path('runtime/extension/' . $extensionId),
  143. base_path('vendor/six-shop/' . $extensionId),
  144. base_path('vendor/sixdec/' . $extensionId),
  145. ];
  146. foreach ($possiblePaths as $path) {
  147. if (is_dir($path)) {
  148. return $path;
  149. }
  150. }
  151. throw new RuntimeException("找不到插件路径:{$extensionId}");
  152. }
  153. /**
  154. * 获取前端代码源路径
  155. *
  156. * @param string $extensionId 插件ID
  157. * @return string
  158. */
  159. private function getFrontendSourcePath(string $extensionId): string
  160. {
  161. $pluginPath = $this->getPluginPath($extensionId);
  162. return $pluginPath . '/frontend';
  163. }
  164. /**
  165. * 获取前端代码目标路径
  166. *
  167. * @return string
  168. */
  169. private function getFrontendTargetPath(): string
  170. {
  171. // base_path() 返回 backend 目录
  172. // 需要回到项目根目录
  173. $backendPath = base_path();
  174. $projectRoot = dirname($backendPath);
  175. $realRoot = dirname($projectRoot);
  176. return $realRoot . '/frontend/admin/src/views';
  177. }
  178. /**
  179. * 递归复制目录
  180. *
  181. * @param string $source 源目录
  182. * @param string $target 目标目录
  183. * @return void
  184. */
  185. private function copyDirectory(string $source, string $target): void
  186. {
  187. // 递归复制文件
  188. $iterator = new RecursiveIteratorIterator(
  189. new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS),
  190. RecursiveIteratorIterator::SELF_FIRST
  191. );
  192. foreach ($iterator as $item) {
  193. // 计算目标路径
  194. $subPath = $iterator->getSubPathName();
  195. $targetPath = $target . DIRECTORY_SEPARATOR . $subPath;
  196. if ($item->isDir()) {
  197. // 创建目录
  198. if (!is_dir($targetPath)) {
  199. mkdir($targetPath, 0755, true);
  200. }
  201. } else {
  202. // 复制文件
  203. $targetDir = dirname($targetPath);
  204. if (!is_dir($targetDir)) {
  205. mkdir($targetDir, 0755, true);
  206. }
  207. // 转换 SplFileInfo 对象为字符串路径
  208. copy($item->getPathname(), $targetPath);
  209. }
  210. }
  211. }
  212. /**
  213. * 递归删除目录
  214. *
  215. * @param string $path 目录路径
  216. * @return void
  217. */
  218. private function removeDirectory(string $path): void
  219. {
  220. if (!is_dir($path)) {
  221. return;
  222. }
  223. $iterator = new RecursiveIteratorIterator(
  224. new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS),
  225. RecursiveIteratorIterator::CHILD_FIRST
  226. );
  227. foreach ($iterator as $item) {
  228. if ($item->isDir()) {
  229. rmdir($item->getPathname());
  230. } else {
  231. unlink($item->getPathname());
  232. }
  233. }
  234. rmdir($path);
  235. }
  236. /**
  237. * 记录部署信息
  238. *
  239. * @param string $extensionId 插件ID
  240. * @param array $info 部署信息
  241. * @return void
  242. */
  243. private function logDeploy(string $extensionId, array $info): void
  244. {
  245. $logDir = runtime_path('frontend_deploy');
  246. if (!is_dir($logDir)) {
  247. mkdir($logDir, 0755, true);
  248. }
  249. $logFile = $logDir . '/deployed.json';
  250. // 读取现有记录
  251. $deployed = [];
  252. if (file_exists($logFile)) {
  253. $content = file_get_contents($logFile);
  254. $deployed = json_decode($content, true) ?: [];
  255. }
  256. // 添加新记录
  257. if (!isset($deployed['deployed'])) {
  258. $deployed['deployed'] = [];
  259. }
  260. $deployed['deployed'][] = array_merge([
  261. 'extension_id' => $extensionId,
  262. 'deployed_at' => date('Y-m-d H:i:s'),
  263. ], $info);
  264. // 写入文件
  265. file_put_contents(
  266. $logFile,
  267. json_encode($deployed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
  268. );
  269. }
  270. /**
  271. * 记录删除信息
  272. *
  273. * @param string $extensionId 插件ID
  274. * @return void
  275. */
  276. private function logRemove(string $extensionId): void
  277. {
  278. $logDir = runtime_path('frontend_deploy');
  279. if (!is_dir($logDir)) {
  280. mkdir($logDir, 0755, true);
  281. }
  282. $logFile = $logDir . '/deployed.json';
  283. // 读取现有记录
  284. $deployed = [];
  285. if (file_exists($logFile)) {
  286. $content = file_get_contents($logFile);
  287. $deployed = json_decode($content, true) ?: [];
  288. }
  289. // 添加删除记录
  290. if (!isset($deployed['removed'])) {
  291. $deployed['removed'] = [];
  292. }
  293. $deployed['removed'][] = [
  294. 'extension_id' => $extensionId,
  295. 'removed_at' => date('Y-m-d H:i:s'),
  296. ];
  297. // 写入文件
  298. file_put_contents(
  299. $logFile,
  300. json_encode($deployed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
  301. );
  302. }
  303. }