Compare commits

..

15 Commits

Author SHA1 Message Date
88ec58a15a fix(ota): 修复APK下载URL构建中的重复路径前缀问题
- 兼容数据库中可能出现的重复 /uploads/ 前缀
- 更新相对路径参数描述,支持可能含 /uploads/ 前缀的情况
- 移除强制HTTPS协议的冗余注释
- 添加正则表达式去除重复的 /uploads/ 前缀
- 简化路径拼接逻辑,避免重复前缀导致的URL错误
2026-04-10 10:31:06 +08:00
be72e73423 fix(ota): 修复APK下载URL构建逻辑
- 更新注释说明relativePath参数已包含/uploads/前缀
- 移除硬编码的/uploads/路径拼接
- 添加注释解释FastAdmin存储路径结构
- 优化URL构建避免重复路径分隔符
2026-04-10 10:27:58 +08:00
73f5e7fa14 refactor(ota): 优化文件路径计算逻辑
- 将 root_path() 函数调用替换为 ROOT_PATH 常量
- 提高代码可读性和性能
- 统一路径常量使用方式
2026-04-10 09:07:55 +08:00
125518aa98 feat(ota): 添加OTA版本管理功能
- 实现OTA版本列表页面,支持版本信息展示
- 添加版本名称、版本号、APK文件等字段显示
- 集成文件上传和SHA-256校验功能
- 实现强制更新开关和状态管理
- 添加发布按钮支持版本发布操作
- 集成表格增删改查基础功能
- 实现文件大小格式化显示
- 添加目标设备
2026-04-10 09:02:22 +08:00
d635b405b9 feat(ota): 添加OTA版本管理功能
- 实现OTA版本列表页面,支持版本信息展示
- 添加版本名称、版本号、APK文件等字段显示
- 集成文件上传和SHA-256校验功能
- 实现强制更新开关和状态管理
- 添加发布按钮支持版本发布操作
- 集成表格增删改查基础功能
- 实现文件大小格式化显示
- 添加目标设备和最低版本配置选项
2026-04-10 09:00:22 +08:00
bda03b1011 fix(admin): 移除OTA版本管理中的selectpage类
- 移除了添加页面中creator_id字段的selectpage类
- 移除了编辑页面中creator_id字段的selectpage类
- 保持了表单验证规则和其他属性不变
- 确保creator_id字段正常显示和功能不受影响
2026-04-09 17:55:57 +08:00
1e22f5b452 feat(ota): 新增OTA远程升级功能
- 创建OTA版本管理表结构,支持版本名称、构建号、APK文件等信息存储
- 实现后台OTA版本管理界面,包含新增、编辑、删除和发布功能
- 开发API接口用于设备版本检查和更新包下载
- 实现版本发布逻辑,自动归档旧版本并计算APK文件哈希值
- 添加强制更新、目标设备白名单和最低版本限制功能
- 集成文件上传和选择组件,支持APK文件管理
- 实现版本状态管理(草稿、已发布、已归档)和权限控制
2026-04-09 17:51:34 +08:00
ab27bb6bf6 feat(apk): 添加最新版本接口并扩展响应数据
- 在现有接口中增加 version 字段返回
- 新增 latest_version 接口用于获取最新APK版本
- 从附件表按创建时间倒序查找最新APK记录
- 统一返回版本号信息到客户端
- 添加暂无安装包的错误处理逻辑
2026-04-09 14:01:39 +08:00
e2cb70911b feat(apk): 添加文件名返回字段
- 在响应数据中增加 filename 字段
- 返回附件的原始文件名信息
2026-04-09 11:18:55 +08:00
925f891fa9 refactor(apk): 优化安装包接口响应结构
- 将响应数据改为只返回 url 字段
- 移除附件对象的完整数组返回
- 统一响应格式为标准 URL 结构
2026-04-09 11:14:54 +08:00
11ba25ab2b skip ci: 移除APK控制器的登录和权限检查
- 添加了$noNeedLogin属性设置为['*']
- 添加了$noNeedRight属性设置为['*']
2026-04-09 11:12:09 +08:00
a2fdae02d8 refactor(apk): 将Apk控制器从Controller基类改为Api基类
- 替换app\common\controller\Api为Controller基类
- 继承Api基类以获得API控制器功能
- 移除无用的think\Controller导入
- 保持控制器基本结构不变
2026-04-09 11:11:33 +08:00
91e2ee5fee feat(api): 添加APK管理控制器和模型
- 创建Apk控制器实现完整的CRUD操作接口
- 添加Apk模型定义数据表结构
- 实现latest方法获取最新安装包文件
- 集成Attachment模型查询分类为apk的附件
- 提供统一的成功失败响应处理机制
2026-04-09 11:08:57 +08:00
5d9b2c1e9a i18n(config): 更新站点配置中的中文本地化
- 将 categorytype 选项从英文翻译为中文:Default -> 默认,Page -> 单页,Article -> 文章
- 将 configgroup 选项从英文翻译为中文:Basic -> 基础配置,Email -> 邮件配置,Dictionary -> 字典配置,User -> 会员配置,Example -> 示例分组
- 将 attachmentcategory 选项从英文翻译为中文:Category1 -> 分类一,Category2 -> 分类二,Custom -> 自定义
- 添加新的附件分类 apk -> 安装包
2026-04-09 10:41:47 +08:00
9bf98361ec config(upload): 更新上传配置限制
- 将最大上传大小从 10mb 增加到 1024mb
- 添加 apk 文件类型到允许的 MIME 类型列表中
2026-04-09 10:39:55 +08:00
15 changed files with 881 additions and 16 deletions

View 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中对应的方法复制到当前控制器,然后进行修改
*/
}

View 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' => '更新时间'
];

View 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 ? '是' : '否';
}
}

View 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' => [],
];
}

View 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" 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>

View 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" 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>

View 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>

View File

@@ -0,0 +1,127 @@
<?php
namespace app\api\controller;
use app\common\controller\Api;
use app\common\model\Attachment;
use think\Request;
class Apk extends Api
{
protected $noNeedLogin = ['*'];
protected $noNeedRight = ['*'];
/**
* 显示资源列表
*
* @return \think\Response
*/
public function index()
{
//
}
/**
* 显示创建资源表单页.
*
* @return \think\Response
*/
public function create()
{
//
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function save(Request $request)
{
//
}
/**
* 显示指定的资源
*
* @param int $id
* @return \think\Response
*/
public function read($id)
{
//
}
/**
* 显示编辑资源表单页.
*
* @param int $id
* @return \think\Response
*/
public function edit($id)
{
//
}
/**
* 保存更新的资源
*
* @param \think\Request $request
* @param int $id
* @return \think\Response
*/
public function update(Request $request, $id)
{
//
}
/**
* 删除指定资源
*
* @param int $id
* @return \think\Response
*/
public function delete($id)
{
//
}
/**
* 获取分类为"apk"的最新安装包文件
*
* @return \think\Response
*/
public function latest()
{
$attachment = Attachment::where('category', 'apk')
->order('createtime', 'desc')
->find();
if (!$attachment) {
$this->error('暂无安装包');
}
$response = [
'url' => $this->request->domain() . $attachment->url,
'filename' => $attachment->filename,
'version' => $attachment->extparam,
];
$this->success('获取成功', $response);
}
public function latest_version(){
$attachment = Attachment::where('category', 'apk')
->order('createtime', 'desc')
->find();
if (!$attachment) {
$this->error('暂无安装包');
}
$response = [
'version' => $attachment->extparam,
];
$this->success('获取成功', $response);
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace app\api\controller;
use app\common\controller\Api;
use app\common\model\OtaVersion;
use think\Exception;
use think\Log;
/**
* OTA 远程升级接口
*
* 无需鉴权,公开接口
*/
class Ota extends Api
{
// 无需登录
protected $noNeedLogin = ['checkVersion'];
// 无需鉴权
protected $noNeedRight = ['*'];
/**
* 检查设备版本更新
*
* GET /api/ota/checkVersion
*
* @param string $imei 设备 Android ID
* @param string $version 当前版本名
* @param string $build 当前构建号
*/
public function checkVersion()
{
$imei = $this->request->get('imei', '');
$version = $this->request->get('version', '');
$build = $this->request->get('build', '');
if (empty($imei)) {
$this->error('设备标识不能为空');
}
// 记录检查日志
Log::info("OTA检查: imei={$imei}, version={$version}, build={$build}");
// 查询最新已发布版本
$latestVersion = OtaVersion::where('status', 'published')
->order('version_code', 'desc')
->find();
if (!$latestVersion) {
$this->error('暂无可用版本');
}
// 转换为整数比较版本号
$localBuild = intval($build);
$serverBuild = intval($latestVersion['version_code']);
// 如果云端版本不高于本地版本,返回无更新
if ($serverBuild <= $localBuild) {
$this->success('已是最新版本', null);
}
// 检查最低版本限制
if (!empty($latestVersion['min_version'])) {
// 简单字符串比较版本名
if (version_compare($version, $latestVersion['min_version'], '<')) {
$this->error('当前版本过低,请先升级到 ' . $latestVersion['min_version']);
}
}
// 检查设备白名单(如果设置了 target_devices
if (!empty($latestVersion['target_devices'])) {
$targets = array_map('trim', explode(',', $latestVersion['target_devices']));
if (!in_array($imei, $targets)) {
$this->error('该设备不在此次升级范围内');
}
}
// 构建返回数据
$apkUrl = $this->buildApkUrl($latestVersion['apk_file']);
$sha256 = $latestVersion['sha256'] ?: '';
// 如果 sha256 为空或 fileSize 为空,尝试从文件计算
if (empty($sha256) || empty($latestVersion['file_size'])) {
$fileInfo = $this->calculateFileInfo($latestVersion['apk_file']);
if ($fileInfo) {
if (empty($sha256)) {
$sha256 = $fileInfo['sha256'];
// 可选:自动更新数据库
// OtaVersion::update(['sha256' => $sha256], ['id' => $latestVersion['id']]);
}
if (empty($latestVersion['file_size'])) {
$latestVersion['file_size'] = $fileInfo['file_size'];
}
}
}
$data = [
'versionName' => $latestVersion['version_name'],
'versionCode' => (int)$latestVersion['version_code'],
'downloadUrl' => $apkUrl,
'updateLog' => $latestVersion['update_log'],
'fileSize' => (int)($latestVersion['file_size'] ?? 0),
'sha256' => $sha256,
'isForceUpdate' => (bool)$latestVersion['is_force_update'],
];
$this->success('发现新版本', $data);
}
/**
* 构建 APK 下载 URL
*
* 客户端要求必须使用 HTTPS 协议,此处强制转换为 HTTPS。
* 兼容数据库中可能出现的重复 /uploads/ 前缀。
*
* @param string $relativePath 相对路径(可能含 /uploads/ 前缀)
* @return string 完整 HTTPS URL
*/
private function buildApkUrl($relativePath)
{
$domain = request()->domain();
// 强制使用 HTTPS
$domain = preg_replace('/^http:\/\//i', 'https://', $domain);
// 去除重复的 /uploads/ 前缀(如 /uploads/uploads/ → /uploads/
$relativePath = preg_replace('#^/?(uploads/)+#', 'uploads/', $relativePath);
return rtrim($domain, '/') . '/' . $relativePath;
}
/**
* 计算 APK 文件的 SHA-256 和文件大小
*
* @param string $relativePath 相对路径
* @return array|null ['sha256' => string, 'file_size' => int]
*/
private function calculateFileInfo($relativePath)
{
$fullPath = ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . ltrim($relativePath, '/');
if (!file_exists($fullPath)) {
return null;
}
$fileSize = filesize($fullPath);
$sha256 = hash_file('sha256', $fullPath);
return [
'sha256' => $sha256,
'file_size' => $fileSize,
];
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace app\api\model;
use think\Model;
class Apk extends Model
{
//
}

View File

@@ -0,0 +1,57 @@
<?php
namespace app\common\model;
use think\Model;
/**
* OTA 版本模型
*
* 供 API 控制器使用的公共模型,与后台管理模型共享数据访问。
*/
class OtaVersion extends Model
{
// 表名
protected $name = 'ota_version';
// 自动写入时间戳
protected $autoWriteTimestamp = 'int';
// 时间字段格式
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
// 追加属性
protected $append = [
'is_force_update_text',
'status_text',
];
/**
* 获取强制更新状态文本
*
* @return string
*/
public function getIsForceUpdateTextAttr()
{
return $this->getData('is_force_update') ? '强制更新' : '可选更新';
}
/**
* 获取状态文本
*
* @param string $value
* @param array $data
* @return string
*/
public function getStatusTextAttr($value, $data)
{
$statusMap = [
'draft' => '草稿',
'published' => '已发布',
'archived' => '已归档',
];
$status = $data['status'] ?? '';
return $statusMap[$status] ?? '未知';
}
}

View File

@@ -15,24 +15,18 @@ return array (
'fixedpage' => 'dashboard',
'categorytype' =>
array (
'default' => 'Default',
'page' => 'Page',
'article' => 'Article',
'default' => '默认',
'page' => '单页',
'article' => '文章',
'test' => 'Test',
),
'configgroup' =>
array (
'basic' => 'Basic',
'email' => 'Email',
'dictionary' => 'Dictionary',
'user' => 'User',
'example' => 'Example',
),
'attachmentcategory' =>
array (
'category1' => 'Category1',
'category2' => 'Category2',
'custom' => 'Custom',
'basic' => '基础配置',
'email' => '邮件配置',
'dictionary' => '字典配置',
'user' => '会员配置',
'example' => '示例分组',
),
'mail_type' => '1',
'mail_smtp_host' => 'smtp.qq.com',
@@ -41,4 +35,11 @@ return array (
'mail_smtp_pass' => '',
'mail_verify_type' => '2',
'mail_from' => '',
'attachmentcategory' =>
array (
'category1' => '分类一',
'category2' => '分类二',
'custom' => '自定义',
'apk' => '安装包',
),
);

View File

@@ -17,12 +17,12 @@ return [
/**
* 最大可上传大小
*/
'maxsize' => '10mb',
'maxsize' => '1024mb',
/**
* 可上传的文件类型
* 如配置允许 pdf,ppt,docx,svg 等可能含有脚本的文件时,请先从服务器配置此类文件直接下载而不是预览
*/
'mimetype' => 'jpg,png,bmp,jpeg,gif,webp,zip,rar,wav,mp4,mp3,webm',
'mimetype' => 'jpg,png,bmp,jpeg,gif,webp,zip,rar,wav,mp4,mp3,webm,apk',
/**
* 是否支持批量上传
*/

View File

@@ -0,0 +1,86 @@
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
var Controller = {
index: function () {
// 初始化表格参数配置
Table.api.init({
extend: {
index_url: 'ota/version/index' + location.search,
add_url: 'ota/version/add',
edit_url: 'ota/version/edit',
del_url: 'ota/version/del',
multi_url: 'ota/version/multi',
import_url: 'ota/version/import',
table: 'ota_version',
}
});
var table = $("#table");
// 初始化表格
table.bootstrapTable({
url: $.fn.bootstrapTable.defaults.extend.index_url,
pk: 'id',
sortName: 'id',
fixedColumns: true,
fixedRightNumber: 1,
columns: [
[
{checkbox: true},
{field: 'id', title: __('Id'), sortable: true},
{field: 'version_name', title: '版本名称', operate: 'LIKE'},
{field: 'version_code', title: '版本号', operate: false},
{field: 'apk_file', title: 'APK文件', operate: false, formatter: Table.api.formatter.file},
{field: 'file_size', title: '文件大小', operate: false, formatter: function (value) {
if (!value || value === 0) return '-';
var mb = (value / 1024 / 1024).toFixed(1);
return mb + ' MB';
}},
{field: 'sha256', title: 'SHA-256', operate: false, formatter: function (value) {
if (!value) return '-';
return '<code title="' + value + '">' + value.substring(0, 16) + '...</code>';
}},
{field: 'is_force_update', title: '强制更新', searchList: {"0":"否","1":"是"}, formatter: Table.api.formatter.toggle},
{field: 'status', title: '状态', searchList: {"draft": __('Draft'), "published": __('Published'), "archived": __('Archived')}, formatter: Table.api.formatter.status},
{field: 'target_devices', title: '目标设备', operate: false, visible: false},
{field: 'min_version', title: '最低版本', operate: 'LIKE', visible: false},
{field: 'creator_id', title: __('Creator_id'), visible: false},
{field: 'createtime', title: '创建时间', operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime},
{field: 'updatetime', title: '更新时间', operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, visible: false},
{field: 'operate', title: __('Operate'), table: table, events: Table.api.events.operate,
buttons: [
{
name: 'publish',
text: '发布',
title: '发布此版本',
classname: 'btn btn-xs btn-success btn-ajax',
url: 'ota/version/publish',
icon: 'fa fa-cloud-upload',
confirm: '确认发布此版本?发布后其他已发布版本将自动归档。',
success: function () {
table.bootstrapTable('refresh');
}
}
],
formatter: Table.api.formatter.operate}
]
]
});
// 为表格绑定事件
Table.api.bindevent(table);
},
add: function () {
Controller.api.bindevent();
},
edit: function () {
Controller.api.bindevent();
},
api: {
bindevent: function () {
Form.api.bindevent($("form[role=form]"));
}
}
};
return Controller;
});

22
sql/ota_version.sql Normal file
View File

@@ -0,0 +1,22 @@
-- OTA 版本管理表
-- 用于芬太尼智能药品管理柜的 OTA 远程升级功能
CREATE TABLE IF NOT EXISTS `ota_version` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`version_name` VARCHAR(20) NOT NULL COMMENT '版本名称,如 1.1.0',
`version_code` INT(11) UNSIGNED NOT NULL COMMENT '版本号(构建号),用于比较',
`apk_file` VARCHAR(255) NOT NULL COMMENT 'APK 文件路径(相对于 upload 目录)',
`file_size` BIGINT(20) UNSIGNED DEFAULT NULL COMMENT '文件大小(字节)',
`sha256` CHAR(64) DEFAULT NULL COMMENT 'APK 文件 SHA-256 哈希值',
`update_log` TEXT NOT NULL COMMENT '更新日志',
`is_force_update` TINYINT(1) UNSIGNED NOT NULL DEFAULT '0' COMMENT '是否强制更新0=否1=是',
`status` ENUM('draft','published','archived') NOT NULL DEFAULT 'draft' COMMENT '状态:草稿/已发布/已归档',
`target_devices` TEXT DEFAULT NULL COMMENT '目标设备 IMEI逗号分隔NULL=所有设备',
`min_version` VARCHAR(20) DEFAULT NULL COMMENT '最低可升级版本NULL=不限制',
`creator_id` INT(11) UNSIGNED DEFAULT NULL COMMENT '创建人ID',
`createtime` INT(10) UNSIGNED DEFAULT NULL COMMENT '创建时间',
`updatetime` INT(10) UNSIGNED DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_version_code` (`version_code`),
KEY `idx_status_version` (`status`, `version_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OTA 版本管理表';