feat: OTA热更新 — 版本检测API + App端WGT下载安装 + app_versions表
This commit is contained in:
36
backend/alembic/versions/c9d0e1f2a3b4_add_app_versions.py
Normal file
36
backend/alembic/versions/c9d0e1f2a3b4_add_app_versions.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""add_app_versions
|
||||||
|
|
||||||
|
Revision ID: c9d0e1f2a3b4
|
||||||
|
Revises: b8c9d0e1f2a3
|
||||||
|
Create Date: 2026-08-07
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "c9d0e1f2a3b4"
|
||||||
|
down_revision: Union[str, None] = "b8c9d0e1f2a3"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"app_versions",
|
||||||
|
sa.Column("id", sa.UUID(), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("version", sa.String(20), nullable=False, unique=True, comment="版本号"),
|
||||||
|
sa.Column("version_code", sa.Integer(), nullable=False, server_default="100", comment="数字版本号"),
|
||||||
|
sa.Column("wgt_url", sa.String(500), nullable=False, comment="WGT下载地址"),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True, comment="更新说明"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), comment="是否启用"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
# 插入初始版本记录
|
||||||
|
op.execute(
|
||||||
|
"INSERT INTO app_versions (version, version_code, wgt_url, description, is_active) "
|
||||||
|
"VALUES ('T1.0.1', 101, '', '初始版本', true)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("app_versions")
|
||||||
55
backend/app/api/v1/endpoints/app_version.py
Normal file
55
backend/app/api/v1/endpoints/app_version.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"""App 版本更新 API — OTA 热更新检测"""
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.app_version import AppVersion
|
||||||
|
from app.schemas.app_version import AppVersionResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/app", tags=["App版本"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/version", response_model=AppVersionResponse)
|
||||||
|
async def check_version(
|
||||||
|
current: str = Query(..., description="当前 App 版本号,如 T1.0.0"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""检测是否有新版本可用"""
|
||||||
|
# 查询最新的激活版本
|
||||||
|
result = await db.execute(
|
||||||
|
select(AppVersion)
|
||||||
|
.where(AppVersion.is_active.is_(True))
|
||||||
|
.order_by(desc(AppVersion.version_code))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
latest = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not latest:
|
||||||
|
# 无版本记录 → 无更新
|
||||||
|
return AppVersionResponse(
|
||||||
|
version=current,
|
||||||
|
version_code=0,
|
||||||
|
has_update=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 比较版本号
|
||||||
|
has_update = latest.version_code > _parse_version_code(current)
|
||||||
|
|
||||||
|
return AppVersionResponse(
|
||||||
|
version=latest.version,
|
||||||
|
version_code=latest.version_code,
|
||||||
|
has_update=has_update,
|
||||||
|
wgt_url=latest.wgt_url if has_update else None,
|
||||||
|
description=latest.description if has_update else None,
|
||||||
|
force_update=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version_code(version_str: str) -> int:
|
||||||
|
"""从版本字符串提取数字版本号(兜底用最后3位)"""
|
||||||
|
import re
|
||||||
|
nums = re.findall(r"\d+", version_str)
|
||||||
|
if nums:
|
||||||
|
return int("".join(nums[-3:]).ljust(3, "0")[:3])
|
||||||
|
return 0
|
||||||
@ -11,6 +11,7 @@ from app.api.v1.endpoints.users import router as users_router
|
|||||||
from app.api.v1.endpoints.upload import router as upload_router
|
from app.api.v1.endpoints.upload import router as upload_router
|
||||||
from app.api.v1.endpoints.records import router as records_router
|
from app.api.v1.endpoints.records import router as records_router
|
||||||
from app.api.v1.endpoints.notifications import router as notifications_router
|
from app.api.v1.endpoints.notifications import router as notifications_router
|
||||||
|
from app.api.v1.endpoints.app_version import router as app_version_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@ -25,3 +26,4 @@ api_router.include_router(users_router)
|
|||||||
api_router.include_router(upload_router)
|
api_router.include_router(upload_router)
|
||||||
api_router.include_router(records_router)
|
api_router.include_router(records_router)
|
||||||
api_router.include_router(notifications_router)
|
api_router.include_router(notifications_router)
|
||||||
|
api_router.include_router(app_version_router)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from app.models.product import Product
|
|||||||
from app.models.task import Task, TaskRecord
|
from app.models.task import Task, TaskRecord
|
||||||
from app.models.task_log import TaskLog
|
from app.models.task_log import TaskLog
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
|
from app.models.app_version import AppVersion
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"ProductionOrder",
|
"ProductionOrder",
|
||||||
@ -13,4 +14,5 @@ __all__ = [
|
|||||||
"TaskRecord",
|
"TaskRecord",
|
||||||
"TaskLog",
|
"TaskLog",
|
||||||
"Notification",
|
"Notification",
|
||||||
|
"AppVersion",
|
||||||
]
|
]
|
||||||
|
|||||||
44
backend/app/models/app_version.py
Normal file
44
backend/app/models/app_version.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"""App 版本管理模型 — 用于 OTA 热更新"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Boolean, Text, Integer
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.core.time_utils import get_beijing_time
|
||||||
|
|
||||||
|
|
||||||
|
class AppVersion(Base):
|
||||||
|
__tablename__ = "app_versions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
|
||||||
|
version: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, unique=True, comment="版本号,如 T1.0.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
version_code: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=100, comment="数字版本号,用于比较",
|
||||||
|
)
|
||||||
|
|
||||||
|
wgt_url: Mapped[str] = mapped_column(
|
||||||
|
String(500), nullable=False, comment="WGT 升级包下载地址",
|
||||||
|
)
|
||||||
|
|
||||||
|
description: Mapped[str | None] = mapped_column(
|
||||||
|
Text, nullable=True, comment="更新说明",
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=True, comment="是否启用",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=get_beijing_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<AppVersion {self.version}>"
|
||||||
14
backend/app/schemas/app_version.py
Normal file
14
backend/app/schemas/app_version.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"""App 版本 Schema"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AppVersionResponse(BaseModel):
|
||||||
|
"""返回给 App 的版本信息"""
|
||||||
|
version: str
|
||||||
|
version_code: int
|
||||||
|
has_update: bool
|
||||||
|
wgt_url: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
force_update: bool = False
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@ -4,11 +4,18 @@ import { getNotifications } from "./api/notification";
|
|||||||
export default {
|
export default {
|
||||||
onLaunch() {
|
onLaunch() {
|
||||||
console.log("生产流转 T1.0.1 启动");
|
console.log("生产流转 T1.0.1 启动");
|
||||||
|
|
||||||
// 无 Token 跳转登录页
|
// 无 Token 跳转登录页
|
||||||
const token = uni.getStorageSync("access_token");
|
const token = uni.getStorageSync("access_token");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
uni.reLaunch({ url: "/pages/login/login" });
|
uni.reLaunch({ url: "/pages/login/login" });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🚀 OTA 热更新检测(仅 App 端生效)
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
this.checkUpdate();
|
||||||
|
// #endif
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
console.log("App 显示");
|
console.log("App 显示");
|
||||||
@ -18,7 +25,124 @@ export default {
|
|||||||
console.log("App 隐藏");
|
console.log("App 隐藏");
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
/** 更新底部 TabBar「消息」红点角标 */
|
// ==========================================================
|
||||||
|
// OTA 热更新 — 版本检测 + WGT 下载 + 静默安装
|
||||||
|
// ==========================================================
|
||||||
|
checkUpdate() {
|
||||||
|
// 获取当前 App 版本号
|
||||||
|
const currentVersion = plus.runtime.version;
|
||||||
|
const baseUrl = uni.getStorageSync("env_base_url") || "http://172.16.0.198:8011/api/v1";
|
||||||
|
|
||||||
|
console.log("[OTA] 当前版本:", currentVersion, "检测服务器:", baseUrl);
|
||||||
|
|
||||||
|
uni.request({
|
||||||
|
url: `${baseUrl}/app/version?current=${encodeURIComponent(currentVersion)}`,
|
||||||
|
method: "GET",
|
||||||
|
timeout: 8000,
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode !== 200) return;
|
||||||
|
const data = res.data;
|
||||||
|
if (!data || !data.has_update) {
|
||||||
|
console.log("[OTA] 已是最新版本");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[OTA] 发现新版本:", data.version);
|
||||||
|
this.downloadAndInstall(data.wgt_url, data.version, data.description);
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
console.log("[OTA] 版本检测网络失败,跳过");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadAndInstall(wgtUrl, newVersion, description) {
|
||||||
|
if (!wgtUrl) {
|
||||||
|
console.log("[OTA] 无 WGT 下载地址");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹窗询问用户是否更新
|
||||||
|
const content = description
|
||||||
|
? `发现新版本 ${newVersion}\n\n${description}\n\n是否立即更新?`
|
||||||
|
: `发现新版本 ${newVersion},是否立即更新?`;
|
||||||
|
|
||||||
|
uni.showModal({
|
||||||
|
title: "版本更新",
|
||||||
|
content,
|
||||||
|
confirmText: "立即更新",
|
||||||
|
cancelText: "稍后再说",
|
||||||
|
success: (modalRes) => {
|
||||||
|
if (!modalRes.confirm) return;
|
||||||
|
|
||||||
|
// 显示下载进度
|
||||||
|
uni.showLoading({ title: "下载中 0%", mask: true });
|
||||||
|
|
||||||
|
const downloadTask = uni.downloadFile({
|
||||||
|
url: wgtUrl,
|
||||||
|
success: (downloadRes) => {
|
||||||
|
if (downloadRes.statusCode !== 200) {
|
||||||
|
uni.hideLoading();
|
||||||
|
uni.showToast({ title: "下载失败", icon: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showLoading({ title: "安装中...", mask: true });
|
||||||
|
|
||||||
|
// 调用 plus.runtime.install 安装 WGT
|
||||||
|
plus.runtime.install(
|
||||||
|
downloadRes.tempFilePath,
|
||||||
|
{ force: false },
|
||||||
|
() => {
|
||||||
|
uni.hideLoading();
|
||||||
|
console.log("[OTA] WGT 安装成功");
|
||||||
|
uni.showModal({
|
||||||
|
title: "更新完成",
|
||||||
|
content: "新版本已安装,重启后生效。是否立即重启?",
|
||||||
|
confirmText: "立即重启",
|
||||||
|
cancelText: "稍后",
|
||||||
|
success: (restartRes) => {
|
||||||
|
if (restartRes.confirm) {
|
||||||
|
plus.runtime.restart();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
uni.hideLoading();
|
||||||
|
console.error("[OTA] 安装失败:", err.message);
|
||||||
|
uni.showToast({
|
||||||
|
title: "更新失败: " + (err.message || "未知错误"),
|
||||||
|
icon: "none",
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
uni.hideLoading();
|
||||||
|
console.error("[OTA] 下载失败:", err.errMsg);
|
||||||
|
uni.showToast({ title: "下载失败,请检查网络", icon: "none" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 下载进度回调
|
||||||
|
if (downloadTask && downloadTask.onProgressUpdate) {
|
||||||
|
downloadTask.onProgressUpdate((res) => {
|
||||||
|
const pct = res.progress;
|
||||||
|
uni.showLoading({ title: `下载中 ${pct}%`, mask: true });
|
||||||
|
if (pct >= 100) {
|
||||||
|
uni.showLoading({ title: "安装中...", mask: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// TabBar 消息红点
|
||||||
|
// ==========================================================
|
||||||
async updateTabBarBadge() {
|
async updateTabBarBadge() {
|
||||||
try {
|
try {
|
||||||
let user = uni.getStorageSync("user");
|
let user = uni.getStorageSync("user");
|
||||||
|
|||||||
Reference in New Issue
Block a user