feat(ota): 新增OTA远程升级功能
- 创建OTA版本管理表结构,支持版本名称、构建号、APK文件等信息存储 - 实现后台OTA版本管理界面,包含新增、编辑、删除和发布功能 - 开发API接口用于设备版本检查和更新包下载 - 实现版本发布逻辑,自动归档旧版本并计算APK文件哈希值 - 添加强制更新、目标设备白名单和最低版本限制功能 - 集成文件上传和选择组件,支持APK文件管理 - 实现版本状态管理(草稿、已发布、已归档)和权限控制
This commit is contained in:
78
application/admin/controller/ota/Version.php
Normal file
78
application/admin/controller/ota/Version.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\controller\ota;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
|
||||
/**
|
||||
* OTA 版本管理管理
|
||||
*
|
||||
* @icon fa fa-circle-o
|
||||
*/
|
||||
class Version extends Backend
|
||||
{
|
||||
|
||||
/**
|
||||
* Version模型对象
|
||||
* @var \app\admin\model\ota\Version
|
||||
*/
|
||||
protected $model = null;
|
||||
|
||||
public function _initialize()
|
||||
{
|
||||
parent::_initialize();
|
||||
$this->model = new \app\admin\model\ota\Version;
|
||||
$this->view->assign("statusList", $this->model->getStatusList());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 发布版本
|
||||
*
|
||||
* 将指定版本设为 published 状态,同时自动归档其他已发布版本,
|
||||
* 并计算 APK 文件大小和 SHA-256 哈希值。
|
||||
*
|
||||
* @return void
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
* @throws \think\exception\DbException
|
||||
*/
|
||||
public function publish()
|
||||
{
|
||||
$id = $this->request->param('ids');
|
||||
if (!$id) {
|
||||
$this->error('参数错误');
|
||||
}
|
||||
|
||||
// 先归档其他已发布版本
|
||||
$this->model->where('status', 'published')->update(['status' => 'archived']);
|
||||
|
||||
// 获取待发布版本
|
||||
$row = $this->model->get($id);
|
||||
if (!$row) {
|
||||
$this->error('记录不存在');
|
||||
}
|
||||
|
||||
// 计算 APK 文件信息
|
||||
if ($row['apk_file']) {
|
||||
$fullPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . ltrim($row['apk_file'], '/');
|
||||
if (file_exists($fullPath)) {
|
||||
$row->file_size = filesize($fullPath);
|
||||
$row->sha256 = hash_file('sha256', $fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
$row->status = 'published';
|
||||
$row->save();
|
||||
|
||||
$this->success('发布成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认生成的控制器所继承的父类中有index/add/edit/del/multi五个基础方法、destroy/restore/recyclebin三个回收站方法
|
||||
* 因此在当前控制器中可不用编写增删改查的代码,除非需要自己控制这部分逻辑
|
||||
* 需要将application/admin/library/traits/Backend.php中对应的方法复制到当前控制器,然后进行修改
|
||||
*/
|
||||
|
||||
|
||||
}
|
||||
17
application/admin/lang/zh-cn/ota/version.php
Normal file
17
application/admin/lang/zh-cn/ota/version.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'Version_name' => '版本名称,如 1.1.0',
|
||||
'Version_code' => '版本号(构建号),用于比较',
|
||||
'Apk_file' => 'APK 文件路径(相对于 upload 目录)',
|
||||
'File_size' => '文件大小(字节)',
|
||||
'Sha256' => 'APK 文件 SHA-256 哈希值',
|
||||
'Update_log' => '更新日志',
|
||||
'Is_force_update' => '是否强制更新:0=否,1=是',
|
||||
'Status' => '状态:草稿/已发布/已归档',
|
||||
'Target_devices' => '目标设备 IMEI,逗号分隔;NULL=所有设备',
|
||||
'Min_version' => '最低可升级版本,NULL=不限制',
|
||||
'Creator_id' => '创建人ID',
|
||||
'Createtime' => '创建时间',
|
||||
'Updatetime' => '更新时间'
|
||||
];
|
||||
66
application/admin/model/ota/Version.php
Normal file
66
application/admin/model/ota/Version.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\model\ota;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* OTA 版本管理模型
|
||||
*
|
||||
* 支持草稿、发布、归档三种状态流转。
|
||||
*/
|
||||
class Version extends Model
|
||||
{
|
||||
// 表名
|
||||
protected $name = 'ota_version';
|
||||
|
||||
// 自动写入时间戳字段
|
||||
protected $autoWriteTimestamp = 'integer';
|
||||
|
||||
// 定义时间戳字段名
|
||||
protected $createTime = 'createtime';
|
||||
protected $updateTime = 'updatetime';
|
||||
protected $deleteTime = false;
|
||||
|
||||
// 追加属性
|
||||
protected $append = [
|
||||
'is_force_update_text',
|
||||
'status_text'
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取状态列表
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getStatusList()
|
||||
{
|
||||
return ['draft' => __('Draft'), 'published' => __('Published'), 'archived' => __('Archived')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态文本
|
||||
*
|
||||
* @param string $value
|
||||
* @param array $data
|
||||
* @return string
|
||||
*/
|
||||
public function getStatusTextAttr($value, $data)
|
||||
{
|
||||
$value = $value ?: ($data['status'] ?? '');
|
||||
$list = $this->getStatusList();
|
||||
return $list[$value] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取强制更新状态文本
|
||||
*
|
||||
* @param string $value
|
||||
* @param array $data
|
||||
* @return string
|
||||
*/
|
||||
public function getIsForceUpdateTextAttr($value, $data)
|
||||
{
|
||||
return ($data['is_force_update'] ?? 0) == 1 ? '是' : '否';
|
||||
}
|
||||
}
|
||||
27
application/admin/validate/ota/Version.php
Normal file
27
application/admin/validate/ota/Version.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\validate\ota;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class Version extends Validate
|
||||
{
|
||||
/**
|
||||
* 验证规则
|
||||
*/
|
||||
protected $rule = [
|
||||
];
|
||||
/**
|
||||
* 提示消息
|
||||
*/
|
||||
protected $message = [
|
||||
];
|
||||
/**
|
||||
* 验证场景
|
||||
*/
|
||||
protected $scene = [
|
||||
'add' => [],
|
||||
'edit' => [],
|
||||
];
|
||||
|
||||
}
|
||||
89
application/admin/view/ota/version/add.html
Normal file
89
application/admin/view/ota/version/add.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<form id="add-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Version_name')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-version_name" data-rule="required" class="form-control" name="row[version_name]" type="text">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Version_code')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-version_code" data-rule="required" min="0" class="form-control" name="row[version_code]" type="number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Apk_file')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<div class="input-group">
|
||||
<input id="c-apk_file" data-rule="required" class="form-control" size="50" name="row[apk_file]" type="text">
|
||||
<div class="input-group-addon no-border no-padding">
|
||||
<span><button type="button" id="faupload-apk_file" class="btn btn-danger faupload" data-input-id="c-apk_file" data-multiple="false" data-preview-id="p-apk_file"><i class="fa fa-upload"></i> {:__('Upload')}</button></span>
|
||||
<span><button type="button" id="fachoose-apk_file" class="btn btn-primary fachoose" data-input-id="c-apk_file" data-multiple="false"><i class="fa fa-list"></i> {:__('Choose')}</button></span>
|
||||
</div>
|
||||
<span class="msg-box n-right" for="c-apk_file"></span>
|
||||
</div>
|
||||
<ul class="row list-inline faupload-preview" id="p-apk_file"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('File_size')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-file_size" min="0" class="form-control" name="row[file_size]" type="number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Sha256')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-sha256" class="form-control" name="row[sha256]" type="text">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Update_log')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<textarea id="c-update_log" data-rule="required" class="form-control " rows="5" name="row[update_log]" cols="50"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Is_force_update')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-is_force_update" data-rule="required" min="0" class="form-control" name="row[is_force_update]" type="number" value="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Status')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
|
||||
<div class="radio">
|
||||
{foreach name="statusList" item="vo"}
|
||||
<label for="row[status]-{$key}"><input id="row[status]-{$key}" name="row[status]" type="radio" value="{$key}" {in name="key" value="draft"}checked{/in} /> {$vo}</label>
|
||||
{/foreach}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Target_devices')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<textarea id="c-target_devices" class="form-control " rows="5" name="row[target_devices]" cols="50"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Min_version')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-min_version" class="form-control" name="row[min_version]" type="text">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Creator_id')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-creator_id" min="0" data-rule="required" data-source="creator/index" class="form-control selectpage" name="row[creator_id]" type="text" value="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group layer-footer">
|
||||
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
89
application/admin/view/ota/version/edit.html
Normal file
89
application/admin/view/ota/version/edit.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<form id="edit-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Version_name')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-version_name" data-rule="required" class="form-control" name="row[version_name]" type="text" value="{$row.version_name|htmlentities}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Version_code')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-version_code" data-rule="required" min="0" class="form-control" name="row[version_code]" type="number" value="{$row.version_code|htmlentities}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Apk_file')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<div class="input-group">
|
||||
<input id="c-apk_file" data-rule="required" class="form-control" size="50" name="row[apk_file]" type="text" value="{$row.apk_file|htmlentities}">
|
||||
<div class="input-group-addon no-border no-padding">
|
||||
<span><button type="button" id="faupload-apk_file" class="btn btn-danger faupload" data-input-id="c-apk_file" data-multiple="false" data-preview-id="p-apk_file"><i class="fa fa-upload"></i> {:__('Upload')}</button></span>
|
||||
<span><button type="button" id="fachoose-apk_file" class="btn btn-primary fachoose" data-input-id="c-apk_file" data-multiple="false"><i class="fa fa-list"></i> {:__('Choose')}</button></span>
|
||||
</div>
|
||||
<span class="msg-box n-right" for="c-apk_file"></span>
|
||||
</div>
|
||||
<ul class="row list-inline faupload-preview" id="p-apk_file"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('File_size')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-file_size" min="0" class="form-control" name="row[file_size]" type="number" value="{$row.file_size|htmlentities}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Sha256')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-sha256" class="form-control" name="row[sha256]" type="text" value="{$row.sha256|htmlentities}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Update_log')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<textarea id="c-update_log" data-rule="required" class="form-control " rows="5" name="row[update_log]" cols="50">{$row.update_log|htmlentities}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Is_force_update')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-is_force_update" data-rule="required" min="0" class="form-control" name="row[is_force_update]" type="number" value="{$row.is_force_update|htmlentities}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Status')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
|
||||
<div class="radio">
|
||||
{foreach name="statusList" item="vo"}
|
||||
<label for="row[status]-{$key}"><input id="row[status]-{$key}" name="row[status]" type="radio" value="{$key}" {in name="key" value="$row.status"}checked{/in} /> {$vo}</label>
|
||||
{/foreach}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Target_devices')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<textarea id="c-target_devices" class="form-control " rows="5" name="row[target_devices]" cols="50">{$row.target_devices|htmlentities}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Min_version')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-min_version" class="form-control" name="row[min_version]" type="text" value="{$row.min_version|htmlentities}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Creator_id')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-creator_id" min="0" data-rule="required" data-source="creator/index" class="form-control selectpage" name="row[creator_id]" type="text" value="{$row.creator_id|htmlentities}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group layer-footer">
|
||||
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
46
application/admin/view/ota/version/index.html
Normal file
46
application/admin/view/ota/version/index.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<div class="panel panel-default panel-intro">
|
||||
|
||||
<div class="panel-heading">
|
||||
{:build_heading(null,FALSE)}
|
||||
<ul class="nav nav-tabs" data-field="status">
|
||||
<li class="{:$Think.get.status === null ? 'active' : ''}"><a href="#t-all" data-value="" data-toggle="tab">{:__('All')}</a></li>
|
||||
{foreach name="statusList" item="vo"}
|
||||
<li class="{:$Think.get.status === (string)$key ? 'active' : ''}"><a href="#t-{$key}" data-value="{$key}" data-toggle="tab">{$vo}</a></li>
|
||||
{/foreach}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="panel-body">
|
||||
<div id="myTabContent" class="tab-content">
|
||||
<div class="tab-pane fade active in" id="one">
|
||||
<div class="widget-body no-padding">
|
||||
<div id="toolbar" class="toolbar">
|
||||
<a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}" ><i class="fa fa-refresh"></i> </a>
|
||||
<a href="javascript:;" class="btn btn-success btn-add {:$auth->check('ota/version/add')?'':'hide'}" title="{:__('Add')}" ><i class="fa fa-plus"></i> {:__('Add')}</a>
|
||||
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('ota/version/edit')?'':'hide'}" title="{:__('Edit')}" ><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('ota/version/del')?'':'hide'}" title="{:__('Delete')}" ><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||
|
||||
|
||||
<div class="dropdown btn-group {:$auth->check('ota/version/multi')?'':'hide'}">
|
||||
<a class="btn btn-primary btn-more dropdown-toggle btn-disabled disabled" data-toggle="dropdown"><i class="fa fa-cog"></i> {:__('More')}</a>
|
||||
<ul class="dropdown-menu text-left" role="menu">
|
||||
{foreach name="statusList" item="vo"}
|
||||
<li><a class="btn btn-link btn-multi btn-disabled disabled" href="javascript:" data-params="status={$key}">{:__('Set status to ' . $key)}</a></li>
|
||||
{/foreach}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||
data-operate-edit="{:$auth->check('ota/version/edit')}"
|
||||
data-operate-del="{:$auth->check('ota/version/del')}"
|
||||
width="100%">
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user