CRMEB/crmeb/app/services/wechat/RoutineCIServices.php
2026-03-23 14:57:47 +08:00

569 lines
20 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
// +----------------------------------------------------------------------
// | 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 [];
}
}