mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-03-25 14:53:52 +00:00
569 lines
20 KiB
PHP
569 lines
20 KiB
PHP
<?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 [];
|
||
}
|
||
}
|