diff --git a/application/admin/controller/ota/Version.php b/application/admin/controller/ota/Version.php new file mode 100644 index 0000000..f3e9e60 --- /dev/null +++ b/application/admin/controller/ota/Version.php @@ -0,0 +1,78 @@ +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中对应的方法复制到当前控制器,然后进行修改 + */ + + +} diff --git a/application/admin/lang/zh-cn/ota/version.php b/application/admin/lang/zh-cn/ota/version.php new file mode 100644 index 0000000..6c8b505 --- /dev/null +++ b/application/admin/lang/zh-cn/ota/version.php @@ -0,0 +1,17 @@ + '版本名称,如 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' => '更新时间' +]; diff --git a/application/admin/model/ota/Version.php b/application/admin/model/ota/Version.php new file mode 100644 index 0000000..f1c14d0 --- /dev/null +++ b/application/admin/model/ota/Version.php @@ -0,0 +1,66 @@ + __('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 ? '是' : '否'; + } +} diff --git a/application/admin/validate/ota/Version.php b/application/admin/validate/ota/Version.php new file mode 100644 index 0000000..75e8893 --- /dev/null +++ b/application/admin/validate/ota/Version.php @@ -0,0 +1,27 @@ + [], + 'edit' => [], + ]; + +} diff --git a/application/admin/view/ota/version/add.html b/application/admin/view/ota/version/add.html new file mode 100644 index 0000000..d6de741 --- /dev/null +++ b/application/admin/view/ota/version/add.html @@ -0,0 +1,89 @@ +
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+ +
+ + +
+ +
+
    +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    + {foreach name="statusList" item="vo"} + + {/foreach} +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    + +
    diff --git a/application/admin/view/ota/version/edit.html b/application/admin/view/ota/version/edit.html new file mode 100644 index 0000000..16babf0 --- /dev/null +++ b/application/admin/view/ota/version/edit.html @@ -0,0 +1,89 @@ +
    + +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    +
    + +
    + + +
    + +
    +
      +
      +
      +
      + +
      + +
      +
      +
      + +
      + +
      +
      +
      + +
      + +
      +
      +
      + +
      + +
      +
      +
      + +
      + +
      + {foreach name="statusList" item="vo"} + + {/foreach} +
      + +
      +
      +
      + +
      + +
      +
      +
      + +
      + +
      +
      +
      + +
      + +
      +
      + +
      diff --git a/application/admin/view/ota/version/index.html b/application/admin/view/ota/version/index.html new file mode 100644 index 0000000..a0fc51f --- /dev/null +++ b/application/admin/view/ota/version/index.html @@ -0,0 +1,46 @@ +
      + +
      + {:build_heading(null,FALSE)} + +
      + + +
      +
      +
      +
      +
      + + {:__('Add')} + {:__('Edit')} + {:__('Delete')} + + + + + +
      + +
      +
      +
      + +
      +
      +
      diff --git a/application/api/controller/Ota.php b/application/api/controller/Ota.php new file mode 100644 index 0000000..9f09005 --- /dev/null +++ b/application/api/controller/Ota.php @@ -0,0 +1,147 @@ +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。 + * + * @param string $relativePath 相对路径 + * @return string 完整 HTTPS URL + */ + private function buildApkUrl($relativePath) + { + $domain = request()->domain(); + // 强制使用 HTTPS,客户端要求 downloadUrl 必须以 https:// 开头 + $domain = preg_replace('/^http:\/\//i', 'https://', $domain); + return rtrim($domain, '/') . '/uploads/' . ltrim($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, + ]; + } +} \ No newline at end of file diff --git a/application/common/model/OtaVersion.php b/application/common/model/OtaVersion.php new file mode 100644 index 0000000..5716db3 --- /dev/null +++ b/application/common/model/OtaVersion.php @@ -0,0 +1,57 @@ +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] ?? '未知'; + } +} diff --git a/public/assets/js/backend/ota/version.js b/public/assets/js/backend/ota/version.js new file mode 100644 index 0000000..e256745 --- /dev/null +++ b/public/assets/js/backend/ota/version.js @@ -0,0 +1,93 @@ +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', + publish_url: 'ota/version/publish', + 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 '' + value.substring(0, 16) + '...'; + }}, + {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, formatter: function (value, row, index) { + var buttons = []; + + // 发布按钮:仅草稿和已归档版本可发布 + if (row.status === 'draft' || row.status === 'archived') { + buttons.push({ + name: 'publish', + text: '发布', + title: '发布此版本', + classname: 'btn btn-xs btn-success btn-ajax', + url: 'ota/version/publish?ids=' + row.id, + confirm: '确认发布此版本?发布后其他已发布版本将自动归档。', + success: function () { + table.bootstrapTable('refresh'); + } + }); + } + + // 默认操作按钮 + var defaultButtons = Table.api.formatter.operate(value, row, index); + return buttons.join(' ') + ' ' + defaultButtons; + }} + ] + ] + }); + + // 为表格绑定事件 + 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; +}); diff --git a/sql/ota_version.sql b/sql/ota_version.sql new file mode 100644 index 0000000..194b6be --- /dev/null +++ b/sql/ota_version.sql @@ -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 版本管理表';