RoutineCIServices.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. <?php
  2. // +----------------------------------------------------------------------
  3. // | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
  4. // +----------------------------------------------------------------------
  5. // | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
  6. // +----------------------------------------------------------------------
  7. // | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
  8. // +----------------------------------------------------------------------
  9. // | Author: CRMEB Team <admin@crmeb.com>
  10. // +----------------------------------------------------------------------
  11. namespace app\services\wechat;
  12. use app\services\BaseServices;
  13. use crmeb\exceptions\AdminException;
  14. use crmeb\services\FileService;
  15. use think\facade\Log;
  16. /**
  17. * 小程序 CI (Continuous Integration) 核心服务类
  18. *
  19. * 功能概述:
  20. * 本服务类封装了微信官方 miniprogram-ci 工具的调用逻辑,
  21. * 实现小程序代码的自动化上传和预览功能。
  22. *
  23. * 主要功能:
  24. * 1. 上传密钥管理 - 保存/删除小程序代码上传密钥
  25. * 2. 项目准备 - 复制源码并替换 AppId、URL 等配置
  26. * 3. 代码上传 - 调用 miniprogram-ci upload 命令上传代码
  27. * 4. 预览二维码 - 调用 miniprogram-ci preview 命令生成预览码
  28. *
  29. * 依赖工具:
  30. * - Node.js >= 14.0.0
  31. * - npm (Node.js 包管理器)
  32. * - miniprogram-ci (全局安装: npm install miniprogram-ci -g)
  33. *
  34. * 密钥获取:
  35. * 微信公众平台 -> 开发管理 -> 开发设置 -> 小程序代码上传
  36. *
  37. * @see https://developers.weixin.qq.com/miniprogram/dev/devtools/ci.html
  38. * @package app\services\wechat
  39. */
  40. class RoutineCIServices extends BaseServices
  41. {
  42. /**
  43. * 小程序项目文件存储路径
  44. *
  45. * 上传前会将源码复制到此目录,并进行配置替换后再上传。
  46. * 默认路径: public/statics/download
  47. *
  48. * @var string
  49. */
  50. protected $projectPath;
  51. /**
  52. * 小程序代码上传密钥文件存储路径
  53. *
  54. * RSA 私钥文件,用于 miniprogram-ci 的身份验证。
  55. * 默认路径: config/routine_private.key
  56. *
  57. * 安全注意: 此文件包含敏感信息,应设置适当的文件权限,
  58. * 并在 .gitignore 中排除,避免提交到版本控制系统。
  59. *
  60. * @var string
  61. */
  62. protected $privateKeyPath;
  63. /**
  64. * 小程序 AppId
  65. *
  66. * 从系统配置中读取,配置项为 routine_appId。
  67. * 用于标识目标小程序,上传时必须与密钥对应的小程序一致。
  68. *
  69. * @var string
  70. */
  71. protected $appId;
  72. /**
  73. * 构造函数 - 初始化配置路径
  74. *
  75. * 初始化小程序上传所需的各种路径配置:
  76. * - 项目文件存储路径
  77. * - 密钥文件存储路径
  78. * - 从系统配置读取 AppId
  79. */
  80. public function __construct()
  81. {
  82. // 设置项目文件存储目录 (public/statics/download)
  83. $this->projectPath = public_path() . 'statics' . DIRECTORY_SEPARATOR . 'download';
  84. // 设置密钥文件存储路径 (config/routine_private.key)
  85. $this->privateKeyPath = app()->getRootPath() . 'config' . DIRECTORY_SEPARATOR . 'routine_private.key';
  86. // 从系统配置读取小程序 AppId
  87. $this->appId = sys_config('routine_appId', '');
  88. }
  89. /**
  90. * 获取上传配置状态信息
  91. *
  92. * 返回当前小程序上传相关的所有配置状态,
  93. * 前端根据这些信息展示配置状态并引导用户完成配置。
  94. *
  95. * @return array 配置状态信息,包含:
  96. * - app_id: 小程序 AppId
  97. * - app_id_configured: AppId 是否已配置
  98. * - private_key_exists: 密钥文件是否存在
  99. * - private_key_path: 密钥文件路径
  100. * - project_path: 项目文件路径
  101. * - project_exists: 项目目录是否存在
  102. */
  103. public function getUploadConfig(): array
  104. {
  105. return [
  106. 'app_id' => $this->appId, // 小程序 AppId
  107. 'app_id_configured' => !empty($this->appId), // AppId 是否已配置
  108. 'private_key_exists' => file_exists($this->privateKeyPath), // 密钥文件是否存在
  109. 'private_key_path' => $this->privateKeyPath, // 密钥文件完整路径
  110. 'project_path' => $this->projectPath, // 项目文件存储路径
  111. 'project_exists' => is_dir($this->projectPath), // 项目目录是否存在
  112. ];
  113. }
  114. /**
  115. * 保存小程序代码上传密钥
  116. *
  117. * 将从微信公众平台下载的密钥内容保存到服务器。
  118. * 密钥用于 miniprogram-ci 工具的身份验证。
  119. *
  120. * 处理流程:
  121. * 1. 验证密钥格式 (必须以 -----BEGIN RSA PRIVATE KEY----- 开头)
  122. * 2. 确保密钥存储目录存在
  123. * 3. 将密钥内容写入文件
  124. * 4. 设置文件权限为 0600 (仅所有者可读写)
  125. *
  126. * @param string $keyContent 密钥内容 (RSA 私钥 PEM 格式)
  127. * @return bool 保存成功返回 true
  128. * @throws AdminException 密钥格式错误或保存失败时抛出异常
  129. */
  130. public function savePrivateKey(string $keyContent): bool
  131. {
  132. // 验证密钥格式: 必须是 RSA 私钥 PEM 格式
  133. if (strpos($keyContent, '-----BEGIN RSA PRIVATE KEY-----') === false) {
  134. throw new AdminException('无效的密钥格式,请上传正确的小程序代码上传密钥');
  135. }
  136. // 确保密钥存储目录存在
  137. $keyDir = dirname($this->privateKeyPath);
  138. if (!is_dir($keyDir)) {
  139. mkdir($keyDir, 0755, true);
  140. }
  141. // 将密钥内容写入文件
  142. $result = file_put_contents($this->privateKeyPath, $keyContent);
  143. if ($result === false) {
  144. throw new AdminException('密钥保存失败,请检查目录权限');
  145. }
  146. // 设置文件权限为 0600 (仅所有者可读写),提高安全性
  147. chmod($this->privateKeyPath, 0600);
  148. return true;
  149. }
  150. /**
  151. * 删除小程序代码上传密钥
  152. *
  153. * 从服务器上删除已保存的密钥文件。
  154. * 如果密钥文件不存在,则直接返回成功。
  155. *
  156. * @return bool 删除成功或文件不存在时返回 true
  157. */
  158. public function deletePrivateKey(): bool
  159. {
  160. if (file_exists($this->privateKeyPath)) {
  161. return unlink($this->privateKeyPath);
  162. }
  163. return true;
  164. }
  165. /**
  166. * 准备小程序项目文件
  167. *
  168. * 上传前的项目准备工作,包括复制源码和替换配置:
  169. * 1. 清理旧的项目文件 (如果存在)
  170. * 2. 将小程序源码从 mp_view 目录复制到 download 目录
  171. * 3. 替换 project.config.json 中的 appid 和 projectname
  172. * 4. 根据是否开启直播决定是否移除直播插件配置
  173. * 5. 替换代码中的 API 域名为当前服务器域名
  174. *
  175. * @param bool $isLive 是否开启直播功能,默认关闭
  176. * 关闭时会移除 app.json 中的直播插件配置
  177. * @return string 准备完成后的项目路径
  178. * @throws AdminException AppId 未配置或准备过程出错时抛出异常
  179. */
  180. public function prepareProject(bool $isLive = false): string
  181. {
  182. // 检查 AppId 是否已配置
  183. if (empty($this->appId)) {
  184. throw new AdminException('请先配置小程序 AppId');
  185. }
  186. try {
  187. // 步骤1: 清理旧的项目文件
  188. if (is_dir($this->projectPath)) {
  189. $this->deleteDirectory($this->projectPath);
  190. }
  191. // 步骤2: 复制小程序源码到目标目录
  192. // 源目录: public/statics/mp_view (小程序编译后的源码)
  193. /** @var FileService $fileService */
  194. $fileService = app(FileService::class);
  195. $fileService->copyDir(public_path() . 'statics/mp_view', $this->projectPath);
  196. // 步骤3: 替换 project.config.json 中的 appid 和 项目名称
  197. $this->updateConfigJson($this->appId, sys_config('routine_name', ''));
  198. // 步骤4: 如果不开启直播,移除 app.json 中的直播插件配置
  199. if (!$isLive) {
  200. $this->updateAppJson();
  201. }
  202. // 步骤5: 替换代码中的 API 域名为当前服务器域名
  203. $this->updateUrl('https://' . $_SERVER['HTTP_HOST']);
  204. return $this->projectPath;
  205. } catch (\Throwable $e) {
  206. throw new AdminException('准备项目失败: ' . $e->getMessage());
  207. }
  208. }
  209. /**
  210. * 上传小程序代码到微信开发版
  211. *
  212. * 调用 miniprogram-ci upload 命令将小程序代码上传到微信服务器。
  213. * 上传成功后,可在微信公众平台的版本管理中查看新版本。
  214. *
  215. * 执行流程:
  216. * 1. 检查运行环境 (Node.js、密钥文件、miniprogram-ci)
  217. * 2. 准备项目文件
  218. * 3. 构建并执行 upload 命令
  219. * 4. 记录命令输出日志
  220. * 5. 返回上传结果
  221. *
  222. * @param string $version 版本号,格式为 x.x.x (如 1.0.0)
  223. * @param string $desc 版本描述,默认为 "版本 {version}"
  224. * @param bool $isLive 是否开启直播功能,影响项目准备
  225. * @return array 上传结果,包含: success, version, desc, message, output
  226. * @throws AdminException 环境检查失败或上传失败时抛出异常
  227. */
  228. public function upload(string $version, string $desc = '', bool $isLive = false): array
  229. {
  230. // 检查运行环境是否满足要求
  231. $this->checkEnvironment();
  232. // 准备项目文件 (复制、替换配置)
  233. $projectPath = $this->prepareProject($isLive);
  234. // 构建 miniprogram-ci upload 命令
  235. $command = $this->buildUploadCommand($version, $desc);
  236. // 记录命令日志
  237. Log::info('miniprogram-ci upload command: ' . $command);
  238. // 执行命令
  239. $output = [];
  240. $returnCode = 0;
  241. exec($command . ' 2>&1', $output, $returnCode);
  242. $outputStr = implode("\n", $output);
  243. Log::info('miniprogram-ci upload output: ' . $outputStr);
  244. // 检查执行结果,非零返回码表示失败
  245. if ($returnCode !== 0) {
  246. throw new AdminException('上传失败: ' . $outputStr);
  247. }
  248. return [
  249. 'success' => true,
  250. 'version' => $version,
  251. 'desc' => $desc,
  252. 'message' => '上传成功',
  253. 'output' => $outputStr,
  254. ];
  255. }
  256. /**
  257. * 生成小程序预览二维码
  258. *
  259. * 调用 miniprogram-ci preview 命令生成预览二维码。
  260. * 扫描二维码可在手机上预览小程序效果。
  261. *
  262. * 执行流程:
  263. * 1. 检查运行环境
  264. * 2. 准备项目文件
  265. * 3. 构建并执行 preview 命令
  266. * 4. 生成二维码图片并保存
  267. * 5. 返回二维码图片 URL
  268. *
  269. * @param string $pagePath 预览的页面路径 (如 pages/index/index)
  270. * 为空时默认预览小程序首页
  271. * @return array 预览结果,包含: success, qrcode_url, message, output
  272. * @throws AdminException 环境检查失败或预览失败时抛出异常
  273. */
  274. public function preview(string $pagePath = ''): array
  275. {
  276. // 检查运行环境是否满足要求
  277. $this->checkEnvironment();
  278. // 准备项目文件
  279. $projectPath = $this->prepareProject();
  280. // 设置二维码图片保存路径
  281. $qrcodePath = public_path() . 'statics' . DIRECTORY_SEPARATOR . 'routine_preview.jpg';
  282. // 构建 miniprogram-ci preview 命令
  283. $command = $this->buildPreviewCommand($qrcodePath, $pagePath);
  284. // 记录命令日志
  285. Log::info('miniprogram-ci preview command: ' . $command);
  286. // 执行命令
  287. $output = [];
  288. $returnCode = 0;
  289. exec($command . ' 2>&1', $output, $returnCode);
  290. $outputStr = implode("\n", $output);
  291. Log::info('miniprogram-ci preview output: ' . $outputStr);
  292. // 检查执行结果
  293. if ($returnCode !== 0) {
  294. throw new AdminException('预览失败: ' . $outputStr);
  295. }
  296. // 拼接二维码图片的访问 URL,添加时间戳防止缓存
  297. $qrcodeUrl = sys_config('site_url') . '/statics/routine_preview.jpg?t=' . time();
  298. return [
  299. 'success' => true,
  300. 'qrcode_url' => $qrcodeUrl,
  301. 'message' => '预览二维码生成成功',
  302. 'output' => $outputStr,
  303. ];
  304. }
  305. /**
  306. * 构建 miniprogram-ci upload 命令
  307. *
  308. * 构建用于上传小程序代码的命令行字符串。
  309. *
  310. * 命令参数说明:
  311. * - --pp: 项目路径 (project path)
  312. * - --pkp: 密钥文件路径 (private key path)
  313. * - --appid: 小程序 AppId
  314. * - --uv: 上传版本号 (upload version)
  315. * - -r: 上传的机器人编号,默认 1
  316. * - --desc: 版本描述
  317. *
  318. * @param string $version 版本号
  319. * @param string $desc 版本描述,默认为 "版本 {version}"
  320. * @return string 完整的命令行字符串
  321. */
  322. protected function buildUploadCommand(string $version, string $desc = ''): string
  323. {
  324. // 默认版本描述
  325. $desc = $desc ?: '版本 ' . $version;
  326. // 构建 miniprogram-ci upload 命令
  327. $command = sprintf(
  328. 'miniprogram-ci upload --pp "%s" --pkp "%s" --appid "%s" --uv "%s" -r 1 --desc "%s"',
  329. $this->projectPath, // 项目路径
  330. $this->privateKeyPath, // 密钥文件路径
  331. $this->appId, // 小程序 AppId
  332. $version, // 版本号
  333. addslashes($desc) // 版本描述 (转义特殊字符)
  334. );
  335. return $command;
  336. }
  337. /**
  338. * 构建 miniprogram-ci preview 命令
  339. *
  340. * 构建用于生成预览二维码的命令行字符串。
  341. *
  342. * 命令参数说明:
  343. * - --pp: 项目路径
  344. * - --pkp: 密钥文件路径
  345. * - --appid: 小程序 AppId
  346. * - --qrcode-format: 二维码输出格式 (image)
  347. * - --qrcode-output-dest: 二维码输出路径
  348. * - --compile-condition: 编译条件,用于指定预览页面
  349. *
  350. * @param string $qrcodePath 二维码图片保存路径
  351. * @param string $pagePath 预览的页面路径 (可选)
  352. * @return string 完整的命令行字符串
  353. */
  354. protected function buildPreviewCommand(string $qrcodePath, string $pagePath = ''): string
  355. {
  356. // 构建基本的 preview 命令
  357. $command = sprintf(
  358. 'miniprogram-ci preview --pp "%s" --pkp "%s" --appid "%s" --qrcode-format image --qrcode-output-dest "%s"',
  359. $this->projectPath, // 项目路径
  360. $this->privateKeyPath, // 密钥文件路径
  361. $this->appId, // 小程序 AppId
  362. $qrcodePath // 二维码输出路径
  363. );
  364. // 如果指定了预览页面,添加编译条件参数
  365. if ($pagePath) {
  366. $command .= sprintf(' --compile-condition \'{"pathName":"%s"}\'', addslashes($pagePath));
  367. }
  368. return $command;
  369. }
  370. /**
  371. * 检查运行环境是否满足要求
  372. *
  373. * 在执行上传或预览前检查必要的环境条件:
  374. * 1. 小程序 AppId 已配置
  375. * 2. 上传密钥文件已存在
  376. * 3. miniprogram-ci 工具已全局安装
  377. *
  378. * @throws AdminException 任一条件不满足时抛出异常
  379. */
  380. protected function checkEnvironment(): void
  381. {
  382. // 检查1: AppId 是否已配置
  383. if (empty($this->appId)) {
  384. throw new AdminException('请先配置小程序 AppId');
  385. }
  386. // 检查2: 密钥文件是否存在
  387. if (!file_exists($this->privateKeyPath)) {
  388. throw new AdminException('请先上传小程序代码上传密钥');
  389. }
  390. // 检查3: miniprogram-ci 是否已全局安装
  391. $output = [];
  392. exec('which miniprogram-ci 2>&1', $output, $returnCode);
  393. if ($returnCode !== 0) {
  394. throw new AdminException('miniprogram-ci 未安装,请先安装运行环境');
  395. }
  396. }
  397. /**
  398. * 替换项目代码中的 API 域名
  399. *
  400. * 将小程序代码中的默认 API 域名 (https://demo.crmeb.com)
  401. * 替换为当前服务器的域名,确保小程序能正确调用后端接口。
  402. *
  403. * @param string $url 要替换成的新域名 (如 https://your-domain.com)
  404. */
  405. protected function updateUrl(string $url): void
  406. {
  407. // 构建 vendor.js 文件路径 (包含 API 域名配置)
  408. $fileUrl = $this->projectPath . DIRECTORY_SEPARATOR . 'common' . DIRECTORY_SEPARATOR . 'vendor.js';
  409. if (!file_exists($fileUrl)) {
  410. return;
  411. }
  412. // 读取文件内容
  413. $string = file_get_contents($fileUrl);
  414. // 替换默认域名为当前服务器域名
  415. $string = str_replace('https://demo.crmeb.com', $url, $string);
  416. // 写回文件
  417. file_put_contents($fileUrl, $string);
  418. }
  419. /**
  420. * 更新 app.json 配置 - 移除直播插件
  421. *
  422. * 当不需要直播功能时,移除 app.json 中的 live-player-plugin 插件配置。
  423. * 这样可以避免在不使用直播的情况下引入不必要的依赖。
  424. */
  425. protected function updateAppJson(): void
  426. {
  427. // app.json 文件路径
  428. $fileUrl = $this->projectPath . DIRECTORY_SEPARATOR . 'app.json';
  429. if (!file_exists($fileUrl)) {
  430. return;
  431. }
  432. $string = file_get_contents($fileUrl);
  433. // 使用正则表达式移除 live-player-plugin 插件配置
  434. // 匹配格式: , "plugins": { "live-player-plugin": { ... } }
  435. $pattern = '/,\s*"plugins"\s*:\s*\{\s*"live-player-plugin"\s*:\s*\{[^}]*\}\s*\}/s';
  436. $string = preg_replace($pattern, '', $string);
  437. file_put_contents($fileUrl, $string);
  438. }
  439. /**
  440. * 更新 project.config.json 配置
  441. *
  442. * 替换项目配置文件中的 appid 和 projectname,
  443. * 确保上传的小程序使用正确的身份标识。
  444. *
  445. * @param string $appId 小程序 AppId
  446. * @param string $projectName 项目名称 (可选)
  447. */
  448. protected function updateConfigJson(string $appId, string $projectName = ''): void
  449. {
  450. // project.config.json 文件路径
  451. $fileUrl = $this->projectPath . DIRECTORY_SEPARATOR . 'project.config.json';
  452. if (!file_exists($fileUrl)) {
  453. return;
  454. }
  455. $string = file_get_contents($fileUrl);
  456. // 替换 appid
  457. $appIdPattern = '/"appid"\s*:\s*"[^"]*"/';
  458. $string = preg_replace($appIdPattern, '"appid": "' . $appId . '"', $string);
  459. // 替换项目名称 (如果提供了)
  460. if ($projectName) {
  461. $namePattern = '/"projectname"\s*:\s*"[^"]*"/';
  462. $string = preg_replace($namePattern, '"projectname": "' . $projectName . '"', $string);
  463. }
  464. file_put_contents($fileUrl, $string);
  465. }
  466. /**
  467. * 递归删除目录及其所有内容
  468. *
  469. * 用于在准备新项目前清理旧的项目文件。
  470. * 会递归删除指定目录下的所有文件和子目录。
  471. *
  472. * @param string $dir 要删除的目录路径
  473. * @return bool 删除成功返回 true
  474. */
  475. protected function deleteDirectory(string $dir): bool
  476. {
  477. // 目录不存在则直接返回成功
  478. if (!is_dir($dir)) {
  479. return true;
  480. }
  481. // 遍历目录下的所有文件和子目录 (排除 . 和 ..)
  482. $files = array_diff(scandir($dir), ['.', '..']);
  483. foreach ($files as $file) {
  484. $path = $dir . DIRECTORY_SEPARATOR . $file;
  485. // 递归删除子目录,直接删除文件
  486. is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
  487. }
  488. // 删除空目录
  489. return rmdir($dir);
  490. }
  491. /**
  492. * 获取上传历史记录 (待实现)
  493. *
  494. * 该方法用于返回小程序代码的历史上传记录,
  495. * 包括版本号、上传时间、上传人等信息。
  496. *
  497. * @return array 上传历史记录数组
  498. */
  499. public function getUploadHistory(): array
  500. {
  501. // TODO: 实现上传历史记录功能
  502. // 可以将上传记录保存到数据库,包括:
  503. // - 版本号、版本描述
  504. // - 上传时间、上传人
  505. // - 上传结果 (成功/失败)
  506. // - 命令输出日志
  507. return [];
  508. }
  509. }