diff --git a/backend/alembic/versions/c9d0e1f2a3b4_add_app_versions.py b/backend/alembic/versions/c9d0e1f2a3b4_add_app_versions.py new file mode 100644 index 0000000..09522e4 --- /dev/null +++ b/backend/alembic/versions/c9d0e1f2a3b4_add_app_versions.py @@ -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") diff --git a/backend/app/api/v1/endpoints/app_version.py b/backend/app/api/v1/endpoints/app_version.py new file mode 100644 index 0000000..69a2176 --- /dev/null +++ b/backend/app/api/v1/endpoints/app_version.py @@ -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 diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index f695c3f..483be7f 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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.records import router as records_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() @@ -25,3 +26,4 @@ api_router.include_router(users_router) api_router.include_router(upload_router) api_router.include_router(records_router) api_router.include_router(notifications_router) +api_router.include_router(app_version_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5b87c0b..5a393ec 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.product import Product from app.models.task import Task, TaskRecord from app.models.task_log import TaskLog from app.models.notification import Notification +from app.models.app_version import AppVersion __all__ = [ "Base", "ProductionOrder", @@ -13,4 +14,5 @@ __all__ = [ "TaskRecord", "TaskLog", "Notification", + "AppVersion", ] diff --git a/backend/app/models/app_version.py b/backend/app/models/app_version.py new file mode 100644 index 0000000..05d18f9 --- /dev/null +++ b/backend/app/models/app_version.py @@ -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"" diff --git a/backend/app/schemas/app_version.py b/backend/app/schemas/app_version.py new file mode 100644 index 0000000..f0d600f --- /dev/null +++ b/backend/app/schemas/app_version.py @@ -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} diff --git a/track-uniapp/src/App.vue b/track-uniapp/src/App.vue index 2c44098..1a57a3a 100644 --- a/track-uniapp/src/App.vue +++ b/track-uniapp/src/App.vue @@ -4,11 +4,18 @@ import { getNotifications } from "./api/notification"; export default { onLaunch() { console.log("生产流转 T1.0.1 启动"); + // 无 Token 跳转登录页 const token = uni.getStorageSync("access_token"); if (!token) { uni.reLaunch({ url: "/pages/login/login" }); + return; } + + // 🚀 OTA 热更新检测(仅 App 端生效) + // #ifdef APP-PLUS + this.checkUpdate(); + // #endif }, onShow() { console.log("App 显示"); @@ -18,7 +25,124 @@ export default { console.log("App 隐藏"); }, 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() { try { let user = uni.getStorageSync("user");