297 lines
13 KiB
PHP
297 lines
13 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace addon\appmall\controller\backend;
|
||
|
||
use think\facade\Db;
|
||
use think\facade\View;
|
||
use Exception;
|
||
use ywxapp\controller\BackendBase;
|
||
use ywxapp\library\StreamZipResponse;
|
||
|
||
/**
|
||
* 主框架版本管理(运营后台,中心站)
|
||
*
|
||
* 上传核心包 zip → 入库(草稿)→ 发布(status=1)→ 客户端可见可下载。
|
||
* 原 upgrade 插件功能,已合并进 market(应用服务中心)。
|
||
*/
|
||
class Framework extends BackendBase
|
||
{
|
||
/**
|
||
* 版本列表 / 页面
|
||
*/
|
||
public function index()
|
||
{
|
||
if ($this->request->isAjax()) {
|
||
$this->ensurePatchColumns();
|
||
$status = $this->request->param('status', '');
|
||
$query = Db::name('appmall_framework_list');
|
||
if ($status !== '') {
|
||
$query->where('status', (int) $status);
|
||
}
|
||
$list = $query->order('id', 'desc')
|
||
->paginate([
|
||
'page' => (int) $this->request->param('page', 1),
|
||
'list_rows' => (int) $this->request->param('limit', 10),
|
||
]);
|
||
$this->result->setCount($list->total())->success($list->items(), '获取成功');
|
||
}
|
||
return View::fetch('framework/index');
|
||
}
|
||
|
||
/**
|
||
* 上传并登记一个主框架核心包(zip),默认草稿态
|
||
*
|
||
* 同一版本号对应一行记录:整包(type=0)写 file_path/file_hash,
|
||
* 增量补丁(type=1)写 patch_path/patch_hash/patch_from,二者可先后上传共存。
|
||
*/
|
||
public function upload()
|
||
{
|
||
try {
|
||
$this->ensurePatchColumns();
|
||
$version = trim(input('version', ''));
|
||
$title = trim(input('title', ''));
|
||
$changelog = input('changelog', '');
|
||
$type = (int) input('type', 0); // 0=整包 1=补丁 2=完整安装包
|
||
$fromVer = trim(input('from_version', ''));
|
||
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
|
||
return $this->result->error('版本号格式不正确(需 x.y.z)');
|
||
}
|
||
if ($type === 1 && $fromVer === '') {
|
||
return $this->result->error('增量补丁必须填写「基础版本」');
|
||
}
|
||
if ($type === 1 && !preg_match('/^\d+\.\d+\.\d+$/', $fromVer)) {
|
||
return $this->result->error('基础版本号格式不正确(需 x.y.z)');
|
||
}
|
||
$file = $this->request->file('file');
|
||
if (empty($file)) {
|
||
return $this->result->error('请上传核心包 zip');
|
||
}
|
||
if (strtolower(substr($file->getOriginalName(), -4)) !== '.zip') {
|
||
return $this->result->error('仅支持 zip 格式');
|
||
}
|
||
|
||
// 整包需校验 ywxapp 核心结构;补丁只校验为合法 zip(含变更文件即可)
|
||
$tmp = $file->getRealPath();
|
||
$zip = new \ZipArchive();
|
||
if ($zip->open($tmp) !== true) {
|
||
return $this->result->error('zip 包无法打开');
|
||
}
|
||
if ($type === 0) {
|
||
$hasCore = false;
|
||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||
$nm = $zip->statIndex($i)['name'];
|
||
if (preg_match('#^(ywxapp/)?(service|controller|library|traits)/#', $nm)) {
|
||
$hasCore = true;
|
||
break;
|
||
}
|
||
}
|
||
$zip->close();
|
||
if (!$hasCore) {
|
||
return $this->result->error('核心包结构不正确:应包含 ywxapp 核心目录(service/controller/...)');
|
||
}
|
||
} elseif ($type === 2) {
|
||
// 完整安装包:必须含 config/ 与占位符 .env,且不得夹带 runtime/vendor/data 或真实 .env.*
|
||
$hasConfig = false;
|
||
$hasEnv = false;
|
||
$bad = false;
|
||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||
$nm = $zip->statIndex($i)['name'];
|
||
if ($nm === 'config' || strpos($nm, 'config/') === 0) {
|
||
$hasConfig = true;
|
||
}
|
||
if ($nm === '.env') {
|
||
$hasEnv = true;
|
||
}
|
||
if (preg_match('#^(runtime/|vendor/|data/|\.env\.)#', $nm)) {
|
||
$bad = true;
|
||
break;
|
||
}
|
||
}
|
||
$zip->close();
|
||
if ($bad) {
|
||
return $this->result->error('安装包结构不正确:不应包含 runtime/vendor/data 或真实 .env.* 文件');
|
||
}
|
||
if (!$hasConfig || !$hasEnv) {
|
||
return $this->result->error('安装包结构不正确:必须包含 config/ 目录与 .env 占位符');
|
||
}
|
||
} else {
|
||
$zip->close();
|
||
}
|
||
|
||
$dir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR;
|
||
if (!is_dir($dir)) {
|
||
@mkdir($dir, 0755, true);
|
||
}
|
||
$saveName = 'ywxapp-' . $version . ($type === 1 ? '-patch' : ($type === 2 ? '-install' : '')) . '.zip';
|
||
$file->move($dir, $saveName);
|
||
$absPath = realpath($dir . $saveName);
|
||
if (!$absPath || !is_file($absPath)) {
|
||
return $this->result->error('核心包保存失败');
|
||
}
|
||
$hash = hash_file('sha256', $absPath);
|
||
|
||
$exist = Db::name('appmall_framework_list')->where('version', $version)->find();
|
||
// 公共字段:标题/日志仅在填写时覆盖,避免后传的包把先前信息清空
|
||
$data = [
|
||
'version' => $version,
|
||
'status' => 0, // 重新上传任一包均回到草稿,需再次发布
|
||
'update_at' => time(),
|
||
];
|
||
if ($title !== '' || !$exist) {
|
||
$data['title'] = $title ?: ('主框架 ' . $version);
|
||
}
|
||
if (trim((string) $changelog) !== '' || !$exist) {
|
||
$data['changelog'] = $changelog;
|
||
}
|
||
if ($type === 1) {
|
||
// 增量补丁:只更新 patch 字段,不动整包
|
||
$data['patch_path'] = $absPath;
|
||
$data['patch_hash'] = $hash;
|
||
$data['patch_from'] = $fromVer;
|
||
$data['from_version'] = $fromVer; // 兼容旧客户端镜像
|
||
} elseif ($type === 2) {
|
||
// 完整安装包:只更新 install 字段,与整包/补丁独立共存
|
||
$data['install_path'] = $absPath;
|
||
$data['install_hash'] = $hash;
|
||
} else {
|
||
// 整包:只更新整包字段,不动补丁
|
||
$data['file_path'] = $absPath;
|
||
$data['file_hash'] = $hash;
|
||
}
|
||
if ($exist) {
|
||
Db::name('appmall_framework_list')->where('id', $exist['id'])->update($data);
|
||
$id = $exist['id'];
|
||
// type 兼容标志:有整包即 0,仅补丁为 1
|
||
$hasFull = ($type === 0) || !empty($exist['file_path']);
|
||
Db::name('appmall_framework_list')->where('id', $id)->update(['type' => $hasFull ? 0 : 1]);
|
||
} else {
|
||
$data['type'] = $type;
|
||
$data['create_at'] = time();
|
||
$id = Db::name('appmall_framework_list')->insertGetId($data);
|
||
}
|
||
$tip = $type === 1 ? '增量补丁已上传' : ($type === 2 ? '完整安装包已上传' : '整包已上传');
|
||
return $this->result->success(['id' => $id], $tip . ',请点击「发布」使其对外可下载');
|
||
} catch (Exception $e) {
|
||
return $this->result->error($e->getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发布:将某版本置为已发布(status=1),客户端即可检测到
|
||
*/
|
||
public function publish()
|
||
{
|
||
try {
|
||
$id = (int) input('id');
|
||
if ($id <= 0) {
|
||
return $this->result->error('缺少版本ID');
|
||
}
|
||
$row = Db::name('appmall_framework_list')->where('id', $id)->find();
|
||
if (empty($row)) {
|
||
return $this->result->error('版本不存在');
|
||
}
|
||
if (empty($row['file_path']) && empty($row['patch_path'])) {
|
||
return $this->result->error('该版本尚未上传任何包,无法发布');
|
||
}
|
||
Db::name('appmall_framework_list')->where('id', $id)->update(['status' => 1, 'update_at' => time()]);
|
||
$tip = empty($row['file_path'])
|
||
? (empty($row['patch_path'])
|
||
? (empty($row['install_path']) ? '已发布' : '已发布(完整安装包,可供全新部署下载)')
|
||
: '已发布(仅增量补丁:只有停在 ' . ($row['patch_from'] ?: $row['from_version']) . ' 的客户端可升级,建议补传整包)')
|
||
: (empty($row['patch_path']) ? '已发布(整包)' : '已发布(整包 + 增量补丁)');
|
||
return $this->result->success([], $tip);
|
||
} catch (Exception $e) {
|
||
return $this->result->error($e->getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除版本(同时删除整包与补丁物理文件)
|
||
*/
|
||
public function delete()
|
||
{
|
||
try {
|
||
$id = (int) input('id');
|
||
if ($id <= 0) {
|
||
return $this->result->error('缺少版本ID');
|
||
}
|
||
$row = Db::name('appmall_framework_list')->where('id', $id)->find();
|
||
foreach (['file_path', 'patch_path', 'install_path'] as $col) {
|
||
if ($row && !empty($row[$col]) && is_file($row[$col])) {
|
||
@unlink($row[$col]);
|
||
}
|
||
}
|
||
Db::name('appmall_framework_list')->where('id', $id)->delete();
|
||
return $this->result->success([], '已删除');
|
||
} catch (Exception $e) {
|
||
return $this->result->error($e->getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 下载核心包(供运营本地校验)
|
||
* ?type=patch 下载增量补丁,默认整包
|
||
*/
|
||
public function download()
|
||
{
|
||
$id = (int) input('id');
|
||
$kind = input('type', 'full');
|
||
$kind = in_array($kind, ['patch', 'install'], true) ? $kind : 'full';
|
||
$row = Db::name('appmall_framework_list')->where('id', $id)->find();
|
||
if ($kind === 'patch') {
|
||
$path = $row['patch_path'] ?? '';
|
||
$suffix = '-patch';
|
||
} elseif ($kind === 'install') {
|
||
$path = $row['install_path'] ?? '';
|
||
$suffix = '-install';
|
||
} else {
|
||
$path = $row['file_path'] ?? '';
|
||
$suffix = '';
|
||
}
|
||
if (empty($row) || empty($path) || !is_file($path)) {
|
||
return json(['code' => 0, 'msg' => '文件不存在']);
|
||
}
|
||
return new StreamZipResponse($path, 'ywxapp-' . $row['version'] . $suffix . '.zip');
|
||
}
|
||
|
||
/**
|
||
* 兼容已安装库:在线补齐 patch_path / patch_hash / patch_from 列
|
||
*/
|
||
private function ensurePatchColumns(): void
|
||
{
|
||
$table = \ywxapp\model\BaseModel::currentPrefix() . 'appmall_framework_list';
|
||
try {
|
||
$defs = [
|
||
'patch_path' => "varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径'",
|
||
'patch_hash' => "varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值'",
|
||
'patch_from' => "varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本'",
|
||
'install_path' => "varchar(255) DEFAULT '' COMMENT '完整安装包 zip 物理路径'",
|
||
'install_hash' => "varchar(64) DEFAULT '' COMMENT '完整安装包 SHA256 校验值'",
|
||
];
|
||
foreach ($defs as $col => $def) {
|
||
$cols = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
|
||
if (empty($cols)) {
|
||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `{$col}` {$def}");
|
||
}
|
||
}
|
||
// 历史数据迁移:旧「补丁行」(type=1 且 patch_path 为空) 把整包字段挪到补丁字段
|
||
Db::execute(
|
||
"UPDATE `{$table}` SET patch_path = file_path, patch_hash = file_hash, patch_from = from_version,"
|
||
. " file_path = '', file_hash = ''"
|
||
. " WHERE type = 1 AND (patch_path = '' OR patch_path IS NULL) AND file_path <> ''"
|
||
);
|
||
} catch (Exception $e) {
|
||
// ignore
|
||
}
|
||
}
|
||
}
|