| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568 |
- <?php
- // +----------------------------------------------------------------------
- // | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
- // +----------------------------------------------------------------------
- // | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
- // +----------------------------------------------------------------------
- // | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
- // +----------------------------------------------------------------------
- // | Author: CRMEB Team <admin@crmeb.com>
- // +----------------------------------------------------------------------
- namespace app\services\wechat;
- use app\services\BaseServices;
- use crmeb\exceptions\AdminException;
- use crmeb\services\FileService;
- use think\facade\Log;
- /**
- * 小程序 CI (Continuous Integration) 核心服务类
- *
- * 功能概述:
- * 本服务类封装了微信官方 miniprogram-ci 工具的调用逻辑,
- * 实现小程序代码的自动化上传和预览功能。
- *
- * 主要功能:
- * 1. 上传密钥管理 - 保存/删除小程序代码上传密钥
- * 2. 项目准备 - 复制源码并替换 AppId、URL 等配置
- * 3. 代码上传 - 调用 miniprogram-ci upload 命令上传代码
- * 4. 预览二维码 - 调用 miniprogram-ci preview 命令生成预览码
- *
- * 依赖工具:
- * - Node.js >= 14.0.0
- * - npm (Node.js 包管理器)
- * - miniprogram-ci (全局安装: npm install miniprogram-ci -g)
- *
- * 密钥获取:
- * 微信公众平台 -> 开发管理 -> 开发设置 -> 小程序代码上传
- *
- * @see https://developers.weixin.qq.com/miniprogram/dev/devtools/ci.html
- * @package app\services\wechat
- */
- class RoutineCIServices extends BaseServices
- {
- /**
- * 小程序项目文件存储路径
- *
- * 上传前会将源码复制到此目录,并进行配置替换后再上传。
- * 默认路径: public/statics/download
- *
- * @var string
- */
- protected $projectPath;
- /**
- * 小程序代码上传密钥文件存储路径
- *
- * RSA 私钥文件,用于 miniprogram-ci 的身份验证。
- * 默认路径: config/routine_private.key
- *
- * 安全注意: 此文件包含敏感信息,应设置适当的文件权限,
- * 并在 .gitignore 中排除,避免提交到版本控制系统。
- *
- * @var string
- */
- protected $privateKeyPath;
- /**
- * 小程序 AppId
- *
- * 从系统配置中读取,配置项为 routine_appId。
- * 用于标识目标小程序,上传时必须与密钥对应的小程序一致。
- *
- * @var string
- */
- protected $appId;
- /**
- * 构造函数 - 初始化配置路径
- *
- * 初始化小程序上传所需的各种路径配置:
- * - 项目文件存储路径
- * - 密钥文件存储路径
- * - 从系统配置读取 AppId
- */
- public function __construct()
- {
- // 设置项目文件存储目录 (public/statics/download)
- $this->projectPath = public_path() . 'statics' . DIRECTORY_SEPARATOR . 'download';
- // 设置密钥文件存储路径 (config/routine_private.key)
- $this->privateKeyPath = app()->getRootPath() . 'config' . DIRECTORY_SEPARATOR . 'routine_private.key';
- // 从系统配置读取小程序 AppId
- $this->appId = sys_config('routine_appId', '');
- }
- /**
- * 获取上传配置状态信息
- *
- * 返回当前小程序上传相关的所有配置状态,
- * 前端根据这些信息展示配置状态并引导用户完成配置。
- *
- * @return array 配置状态信息,包含:
- * - app_id: 小程序 AppId
- * - app_id_configured: AppId 是否已配置
- * - private_key_exists: 密钥文件是否存在
- * - private_key_path: 密钥文件路径
- * - project_path: 项目文件路径
- * - project_exists: 项目目录是否存在
- */
- public function getUploadConfig(): array
- {
- return [
- 'app_id' => $this->appId, // 小程序 AppId
- 'app_id_configured' => !empty($this->appId), // AppId 是否已配置
- 'private_key_exists' => file_exists($this->privateKeyPath), // 密钥文件是否存在
- 'private_key_path' => $this->privateKeyPath, // 密钥文件完整路径
- 'project_path' => $this->projectPath, // 项目文件存储路径
- 'project_exists' => is_dir($this->projectPath), // 项目目录是否存在
- ];
- }
- /**
- * 保存小程序代码上传密钥
- *
- * 将从微信公众平台下载的密钥内容保存到服务器。
- * 密钥用于 miniprogram-ci 工具的身份验证。
- *
- * 处理流程:
- * 1. 验证密钥格式 (必须以 -----BEGIN RSA PRIVATE KEY----- 开头)
- * 2. 确保密钥存储目录存在
- * 3. 将密钥内容写入文件
- * 4. 设置文件权限为 0600 (仅所有者可读写)
- *
- * @param string $keyContent 密钥内容 (RSA 私钥 PEM 格式)
- * @return bool 保存成功返回 true
- * @throws AdminException 密钥格式错误或保存失败时抛出异常
- */
- public function savePrivateKey(string $keyContent): bool
- {
- // 验证密钥格式: 必须是 RSA 私钥 PEM 格式
- if (strpos($keyContent, '-----BEGIN RSA PRIVATE KEY-----') === false) {
- throw new AdminException('无效的密钥格式,请上传正确的小程序代码上传密钥');
- }
- // 确保密钥存储目录存在
- $keyDir = dirname($this->privateKeyPath);
- if (!is_dir($keyDir)) {
- mkdir($keyDir, 0755, true);
- }
- // 将密钥内容写入文件
- $result = file_put_contents($this->privateKeyPath, $keyContent);
- if ($result === false) {
- throw new AdminException('密钥保存失败,请检查目录权限');
- }
- // 设置文件权限为 0600 (仅所有者可读写),提高安全性
- chmod($this->privateKeyPath, 0600);
- return true;
- }
- /**
- * 删除小程序代码上传密钥
- *
- * 从服务器上删除已保存的密钥文件。
- * 如果密钥文件不存在,则直接返回成功。
- *
- * @return bool 删除成功或文件不存在时返回 true
- */
- public function deletePrivateKey(): bool
- {
- if (file_exists($this->privateKeyPath)) {
- return unlink($this->privateKeyPath);
- }
- return true;
- }
- /**
- * 准备小程序项目文件
- *
- * 上传前的项目准备工作,包括复制源码和替换配置:
- * 1. 清理旧的项目文件 (如果存在)
- * 2. 将小程序源码从 mp_view 目录复制到 download 目录
- * 3. 替换 project.config.json 中的 appid 和 projectname
- * 4. 根据是否开启直播决定是否移除直播插件配置
- * 5. 替换代码中的 API 域名为当前服务器域名
- *
- * @param bool $isLive 是否开启直播功能,默认关闭
- * 关闭时会移除 app.json 中的直播插件配置
- * @return string 准备完成后的项目路径
- * @throws AdminException AppId 未配置或准备过程出错时抛出异常
- */
- public function prepareProject(bool $isLive = false): string
- {
- // 检查 AppId 是否已配置
- if (empty($this->appId)) {
- throw new AdminException('请先配置小程序 AppId');
- }
- try {
- // 步骤1: 清理旧的项目文件
- if (is_dir($this->projectPath)) {
- $this->deleteDirectory($this->projectPath);
- }
- // 步骤2: 复制小程序源码到目标目录
- // 源目录: public/statics/mp_view (小程序编译后的源码)
- /** @var FileService $fileService */
- $fileService = app(FileService::class);
- $fileService->copyDir(public_path() . 'statics/mp_view', $this->projectPath);
- // 步骤3: 替换 project.config.json 中的 appid 和 项目名称
- $this->updateConfigJson($this->appId, sys_config('routine_name', ''));
- // 步骤4: 如果不开启直播,移除 app.json 中的直播插件配置
- if (!$isLive) {
- $this->updateAppJson();
- }
- // 步骤5: 替换代码中的 API 域名为当前服务器域名
- $this->updateUrl('https://' . $_SERVER['HTTP_HOST']);
- return $this->projectPath;
- } catch (\Throwable $e) {
- throw new AdminException('准备项目失败: ' . $e->getMessage());
- }
- }
- /**
- * 上传小程序代码到微信开发版
- *
- * 调用 miniprogram-ci upload 命令将小程序代码上传到微信服务器。
- * 上传成功后,可在微信公众平台的版本管理中查看新版本。
- *
- * 执行流程:
- * 1. 检查运行环境 (Node.js、密钥文件、miniprogram-ci)
- * 2. 准备项目文件
- * 3. 构建并执行 upload 命令
- * 4. 记录命令输出日志
- * 5. 返回上传结果
- *
- * @param string $version 版本号,格式为 x.x.x (如 1.0.0)
- * @param string $desc 版本描述,默认为 "版本 {version}"
- * @param bool $isLive 是否开启直播功能,影响项目准备
- * @return array 上传结果,包含: success, version, desc, message, output
- * @throws AdminException 环境检查失败或上传失败时抛出异常
- */
- public function upload(string $version, string $desc = '', bool $isLive = false): array
- {
- // 检查运行环境是否满足要求
- $this->checkEnvironment();
- // 准备项目文件 (复制、替换配置)
- $projectPath = $this->prepareProject($isLive);
- // 构建 miniprogram-ci upload 命令
- $command = $this->buildUploadCommand($version, $desc);
- // 记录命令日志
- Log::info('miniprogram-ci upload command: ' . $command);
- // 执行命令
- $output = [];
- $returnCode = 0;
- exec($command . ' 2>&1', $output, $returnCode);
- $outputStr = implode("\n", $output);
- Log::info('miniprogram-ci upload output: ' . $outputStr);
- // 检查执行结果,非零返回码表示失败
- if ($returnCode !== 0) {
- throw new AdminException('上传失败: ' . $outputStr);
- }
- return [
- 'success' => true,
- 'version' => $version,
- 'desc' => $desc,
- 'message' => '上传成功',
- 'output' => $outputStr,
- ];
- }
- /**
- * 生成小程序预览二维码
- *
- * 调用 miniprogram-ci preview 命令生成预览二维码。
- * 扫描二维码可在手机上预览小程序效果。
- *
- * 执行流程:
- * 1. 检查运行环境
- * 2. 准备项目文件
- * 3. 构建并执行 preview 命令
- * 4. 生成二维码图片并保存
- * 5. 返回二维码图片 URL
- *
- * @param string $pagePath 预览的页面路径 (如 pages/index/index)
- * 为空时默认预览小程序首页
- * @return array 预览结果,包含: success, qrcode_url, message, output
- * @throws AdminException 环境检查失败或预览失败时抛出异常
- */
- public function preview(string $pagePath = ''): array
- {
- // 检查运行环境是否满足要求
- $this->checkEnvironment();
- // 准备项目文件
- $projectPath = $this->prepareProject();
- // 设置二维码图片保存路径
- $qrcodePath = public_path() . 'statics' . DIRECTORY_SEPARATOR . 'routine_preview.jpg';
- // 构建 miniprogram-ci preview 命令
- $command = $this->buildPreviewCommand($qrcodePath, $pagePath);
- // 记录命令日志
- Log::info('miniprogram-ci preview command: ' . $command);
- // 执行命令
- $output = [];
- $returnCode = 0;
- exec($command . ' 2>&1', $output, $returnCode);
- $outputStr = implode("\n", $output);
- Log::info('miniprogram-ci preview output: ' . $outputStr);
- // 检查执行结果
- if ($returnCode !== 0) {
- throw new AdminException('预览失败: ' . $outputStr);
- }
- // 拼接二维码图片的访问 URL,添加时间戳防止缓存
- $qrcodeUrl = sys_config('site_url') . '/statics/routine_preview.jpg?t=' . time();
- return [
- 'success' => true,
- 'qrcode_url' => $qrcodeUrl,
- 'message' => '预览二维码生成成功',
- 'output' => $outputStr,
- ];
- }
- /**
- * 构建 miniprogram-ci upload 命令
- *
- * 构建用于上传小程序代码的命令行字符串。
- *
- * 命令参数说明:
- * - --pp: 项目路径 (project path)
- * - --pkp: 密钥文件路径 (private key path)
- * - --appid: 小程序 AppId
- * - --uv: 上传版本号 (upload version)
- * - -r: 上传的机器人编号,默认 1
- * - --desc: 版本描述
- *
- * @param string $version 版本号
- * @param string $desc 版本描述,默认为 "版本 {version}"
- * @return string 完整的命令行字符串
- */
- protected function buildUploadCommand(string $version, string $desc = ''): string
- {
- // 默认版本描述
- $desc = $desc ?: '版本 ' . $version;
- // 构建 miniprogram-ci upload 命令
- $command = sprintf(
- 'miniprogram-ci upload --pp "%s" --pkp "%s" --appid "%s" --uv "%s" -r 1 --desc "%s"',
- $this->projectPath, // 项目路径
- $this->privateKeyPath, // 密钥文件路径
- $this->appId, // 小程序 AppId
- $version, // 版本号
- addslashes($desc) // 版本描述 (转义特殊字符)
- );
- return $command;
- }
- /**
- * 构建 miniprogram-ci preview 命令
- *
- * 构建用于生成预览二维码的命令行字符串。
- *
- * 命令参数说明:
- * - --pp: 项目路径
- * - --pkp: 密钥文件路径
- * - --appid: 小程序 AppId
- * - --qrcode-format: 二维码输出格式 (image)
- * - --qrcode-output-dest: 二维码输出路径
- * - --compile-condition: 编译条件,用于指定预览页面
- *
- * @param string $qrcodePath 二维码图片保存路径
- * @param string $pagePath 预览的页面路径 (可选)
- * @return string 完整的命令行字符串
- */
- protected function buildPreviewCommand(string $qrcodePath, string $pagePath = ''): string
- {
- // 构建基本的 preview 命令
- $command = sprintf(
- 'miniprogram-ci preview --pp "%s" --pkp "%s" --appid "%s" --qrcode-format image --qrcode-output-dest "%s"',
- $this->projectPath, // 项目路径
- $this->privateKeyPath, // 密钥文件路径
- $this->appId, // 小程序 AppId
- $qrcodePath // 二维码输出路径
- );
- // 如果指定了预览页面,添加编译条件参数
- if ($pagePath) {
- $command .= sprintf(' --compile-condition \'{"pathName":"%s"}\'', addslashes($pagePath));
- }
- return $command;
- }
- /**
- * 检查运行环境是否满足要求
- *
- * 在执行上传或预览前检查必要的环境条件:
- * 1. 小程序 AppId 已配置
- * 2. 上传密钥文件已存在
- * 3. miniprogram-ci 工具已全局安装
- *
- * @throws AdminException 任一条件不满足时抛出异常
- */
- protected function checkEnvironment(): void
- {
- // 检查1: AppId 是否已配置
- if (empty($this->appId)) {
- throw new AdminException('请先配置小程序 AppId');
- }
- // 检查2: 密钥文件是否存在
- if (!file_exists($this->privateKeyPath)) {
- throw new AdminException('请先上传小程序代码上传密钥');
- }
- // 检查3: miniprogram-ci 是否已全局安装
- $output = [];
- exec('which miniprogram-ci 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- throw new AdminException('miniprogram-ci 未安装,请先安装运行环境');
- }
- }
- /**
- * 替换项目代码中的 API 域名
- *
- * 将小程序代码中的默认 API 域名 (https://demo.crmeb.com)
- * 替换为当前服务器的域名,确保小程序能正确调用后端接口。
- *
- * @param string $url 要替换成的新域名 (如 https://your-domain.com)
- */
- protected function updateUrl(string $url): void
- {
- // 构建 vendor.js 文件路径 (包含 API 域名配置)
- $fileUrl = $this->projectPath . DIRECTORY_SEPARATOR . 'common' . DIRECTORY_SEPARATOR . 'vendor.js';
- if (!file_exists($fileUrl)) {
- return;
- }
- // 读取文件内容
- $string = file_get_contents($fileUrl);
- // 替换默认域名为当前服务器域名
- $string = str_replace('https://demo.crmeb.com', $url, $string);
- // 写回文件
- file_put_contents($fileUrl, $string);
- }
- /**
- * 更新 app.json 配置 - 移除直播插件
- *
- * 当不需要直播功能时,移除 app.json 中的 live-player-plugin 插件配置。
- * 这样可以避免在不使用直播的情况下引入不必要的依赖。
- */
- protected function updateAppJson(): void
- {
- // app.json 文件路径
- $fileUrl = $this->projectPath . DIRECTORY_SEPARATOR . 'app.json';
- if (!file_exists($fileUrl)) {
- return;
- }
- $string = file_get_contents($fileUrl);
- // 使用正则表达式移除 live-player-plugin 插件配置
- // 匹配格式: , "plugins": { "live-player-plugin": { ... } }
- $pattern = '/,\s*"plugins"\s*:\s*\{\s*"live-player-plugin"\s*:\s*\{[^}]*\}\s*\}/s';
- $string = preg_replace($pattern, '', $string);
- file_put_contents($fileUrl, $string);
- }
- /**
- * 更新 project.config.json 配置
- *
- * 替换项目配置文件中的 appid 和 projectname,
- * 确保上传的小程序使用正确的身份标识。
- *
- * @param string $appId 小程序 AppId
- * @param string $projectName 项目名称 (可选)
- */
- protected function updateConfigJson(string $appId, string $projectName = ''): void
- {
- // project.config.json 文件路径
- $fileUrl = $this->projectPath . DIRECTORY_SEPARATOR . 'project.config.json';
- if (!file_exists($fileUrl)) {
- return;
- }
- $string = file_get_contents($fileUrl);
- // 替换 appid
- $appIdPattern = '/"appid"\s*:\s*"[^"]*"/';
- $string = preg_replace($appIdPattern, '"appid": "' . $appId . '"', $string);
- // 替换项目名称 (如果提供了)
- if ($projectName) {
- $namePattern = '/"projectname"\s*:\s*"[^"]*"/';
- $string = preg_replace($namePattern, '"projectname": "' . $projectName . '"', $string);
- }
- file_put_contents($fileUrl, $string);
- }
- /**
- * 递归删除目录及其所有内容
- *
- * 用于在准备新项目前清理旧的项目文件。
- * 会递归删除指定目录下的所有文件和子目录。
- *
- * @param string $dir 要删除的目录路径
- * @return bool 删除成功返回 true
- */
- protected function deleteDirectory(string $dir): bool
- {
- // 目录不存在则直接返回成功
- if (!is_dir($dir)) {
- return true;
- }
- // 遍历目录下的所有文件和子目录 (排除 . 和 ..)
- $files = array_diff(scandir($dir), ['.', '..']);
- foreach ($files as $file) {
- $path = $dir . DIRECTORY_SEPARATOR . $file;
- // 递归删除子目录,直接删除文件
- is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
- }
- // 删除空目录
- return rmdir($dir);
- }
- /**
- * 获取上传历史记录 (待实现)
- *
- * 该方法用于返回小程序代码的历史上传记录,
- * 包括版本号、上传时间、上传人等信息。
- *
- * @return array 上传历史记录数组
- */
- public function getUploadHistory(): array
- {
- // TODO: 实现上传历史记录功能
- // 可以将上传记录保存到数据库,包括:
- // - 版本号、版本描述
- // - 上传时间、上传人
- // - 上传结果 (成功/失败)
- // - 命令输出日志
- return [];
- }
- }
|