|
|
@@ -0,0 +1,328 @@
|
|
|
+import logging
|
|
|
+from typing import Optional
|
|
|
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
|
|
+from sqlalchemy.orm import Session
|
|
|
+from sqlalchemy import desc, or_
|
|
|
+
|
|
|
+from app.api.v1 import deps
|
|
|
+from app.models.user import User
|
|
|
+from app.models.client_distribution import ClientDistribution, ClientVersion
|
|
|
+from app.schemas.client_distribution import (
|
|
|
+ ClientDistributionCreate,
|
|
|
+ ClientDistributionUpdate,
|
|
|
+ ClientDistributionResponse,
|
|
|
+ ClientDistributionList,
|
|
|
+ ClientVersionCreate,
|
|
|
+ ClientVersionUpdate,
|
|
|
+ ClientVersionResponse,
|
|
|
+ ClientVersionList,
|
|
|
+)
|
|
|
+from app.core.minio import minio_storage
|
|
|
+
|
|
|
+router = APIRouter()
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+# 安装包允许类型
|
|
|
+ALLOWED_PACKAGE_TYPES = {
|
|
|
+ "application/vnd.android.package-archive", # APK
|
|
|
+ "application/octet-stream", # IPA, generic binary
|
|
|
+}
|
|
|
+MAX_PACKAGE_SIZE = 500 * 1024 * 1024 # 500MB
|
|
|
+
|
|
|
+# 图标允许类型
|
|
|
+ALLOWED_ICON_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
|
|
+MAX_ICON_SIZE = 2 * 1024 * 1024 # 2MB
|
|
|
+
|
|
|
+
|
|
|
+def _resolve_icon_url(icon_url: Optional[str]) -> Optional[str]:
|
|
|
+ """若 icon_url 为 MinIO object_key,转为预签名 URL"""
|
|
|
+ if not icon_url:
|
|
|
+ return icon_url
|
|
|
+ if str(icon_url).startswith("http"):
|
|
|
+ return icon_url
|
|
|
+ return minio_storage.get_distribution_presigned_url(icon_url) or icon_url
|
|
|
+
|
|
|
+
|
|
|
+def _check_distribution_access(db: Session, dist_id: int, user: User) -> ClientDistribution:
|
|
|
+ dist = db.query(ClientDistribution).filter(
|
|
|
+ ClientDistribution.id == dist_id,
|
|
|
+ ClientDistribution.is_deleted == 0
|
|
|
+ ).first()
|
|
|
+ if not dist:
|
|
|
+ raise HTTPException(status_code=404, detail="分发不存在")
|
|
|
+ if user.role != "SUPER_ADMIN" and dist.owner_id != user.id:
|
|
|
+ raise HTTPException(status_code=403, detail="无权限操作此分发")
|
|
|
+ return dist
|
|
|
+
|
|
|
+
|
|
|
+@router.get("/", response_model=ClientDistributionList)
|
|
|
+def list_distributions(
|
|
|
+ skip: int = 0,
|
|
|
+ limit: int = 10,
|
|
|
+ search: Optional[str] = None,
|
|
|
+ db: Session = Depends(deps.get_db),
|
|
|
+ current_user: User = Depends(deps.get_current_active_user),
|
|
|
+):
|
|
|
+ """获取分发列表。超级管理员看全部,开发者只看自己的。"""
|
|
|
+ query = db.query(ClientDistribution).filter(ClientDistribution.is_deleted == 0)
|
|
|
+ if current_user.role != "SUPER_ADMIN":
|
|
|
+ query = query.filter(ClientDistribution.owner_id == current_user.id)
|
|
|
+ if search:
|
|
|
+ query = query.filter(
|
|
|
+ or_(
|
|
|
+ ClientDistribution.name.ilike(f"%{search}%"),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ total = query.count()
|
|
|
+ rows = query.order_by(desc(ClientDistribution.id)).offset(skip).limit(limit).all()
|
|
|
+ items = [
|
|
|
+ ClientDistributionResponse(
|
|
|
+ id=d.id,
|
|
|
+ name=d.name,
|
|
|
+ description=d.description,
|
|
|
+ icon_url=_resolve_icon_url(d.icon_url),
|
|
|
+ icon_object_key=d.icon_url if (d.icon_url and not str(d.icon_url).startswith("http")) else None,
|
|
|
+ owner_id=d.owner_id,
|
|
|
+ created_at=d.created_at,
|
|
|
+ updated_at=d.updated_at,
|
|
|
+ )
|
|
|
+ for d in rows
|
|
|
+ ]
|
|
|
+ return ClientDistributionList(total=total, items=items)
|
|
|
+
|
|
|
+
|
|
|
+@router.post("/", response_model=ClientDistributionResponse)
|
|
|
+def create_distribution(
|
|
|
+ data: ClientDistributionCreate,
|
|
|
+ db: Session = Depends(deps.get_db),
|
|
|
+ current_user: User = Depends(deps.get_current_active_user),
|
|
|
+):
|
|
|
+ """创建分发。仅开发者或超级管理员可创建。"""
|
|
|
+ if current_user.role not in ("SUPER_ADMIN", "DEVELOPER"):
|
|
|
+ raise HTTPException(status_code=403, detail="无权限创建分发")
|
|
|
+ dist = ClientDistribution(
|
|
|
+ name=data.name,
|
|
|
+ description=data.description,
|
|
|
+ icon_url=data.icon_url,
|
|
|
+ owner_id=current_user.id,
|
|
|
+ )
|
|
|
+ db.add(dist)
|
|
|
+ db.commit()
|
|
|
+ db.refresh(dist)
|
|
|
+ return ClientDistributionResponse(
|
|
|
+ id=dist.id,
|
|
|
+ name=dist.name,
|
|
|
+ description=dist.description,
|
|
|
+ icon_url=_resolve_icon_url(dist.icon_url),
|
|
|
+ icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None,
|
|
|
+ owner_id=dist.owner_id,
|
|
|
+ created_at=dist.created_at,
|
|
|
+ updated_at=dist.updated_at,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+@router.post("/icon/upload")
|
|
|
+async def upload_icon(
|
|
|
+ file: UploadFile = File(...),
|
|
|
+ distribution_id: int = Form(0),
|
|
|
+ db: Session = Depends(deps.get_db),
|
|
|
+ current_user: User = Depends(deps.get_current_active_user),
|
|
|
+):
|
|
|
+ """上传分发图标。distribution_id=0 表示创建时的临时图标,>0 表示编辑已有分发。返回 object_key。"""
|
|
|
+ if current_user.role not in ("SUPER_ADMIN", "DEVELOPER"):
|
|
|
+ raise HTTPException(status_code=403, detail="无权限")
|
|
|
+ if distribution_id > 0:
|
|
|
+ _check_distribution_access(db, distribution_id, current_user)
|
|
|
+ content = await file.read()
|
|
|
+ if len(content) > MAX_ICON_SIZE:
|
|
|
+ raise HTTPException(status_code=400, detail="图标大小超过 2MB 限制")
|
|
|
+ ct = file.content_type or "image/png"
|
|
|
+ if ct not in ALLOWED_ICON_TYPES:
|
|
|
+ raise HTTPException(status_code=400, detail="仅支持 PNG、JPG、GIF、WebP 格式")
|
|
|
+ try:
|
|
|
+ object_key = minio_storage.upload_distribution_icon(
|
|
|
+ file_data=content,
|
|
|
+ filename=file.filename or "icon.png",
|
|
|
+ content_type=ct,
|
|
|
+ distribution_id=distribution_id,
|
|
|
+ user_id=current_user.id,
|
|
|
+ )
|
|
|
+ return {"object_key": object_key}
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Icon upload failed: {e}")
|
|
|
+ raise HTTPException(status_code=500, detail="图标上传失败")
|
|
|
+
|
|
|
+
|
|
|
+@router.get("/{dist_id}", response_model=ClientDistributionResponse)
|
|
|
+def get_distribution(
|
|
|
+ dist_id: int,
|
|
|
+ db: Session = Depends(deps.get_db),
|
|
|
+ current_user: User = Depends(deps.get_current_active_user),
|
|
|
+):
|
|
|
+ """获取分发详情"""
|
|
|
+ dist = _check_distribution_access(db, dist_id, current_user)
|
|
|
+ return ClientDistributionResponse(
|
|
|
+ id=dist.id,
|
|
|
+ name=dist.name,
|
|
|
+ description=dist.description,
|
|
|
+ icon_url=_resolve_icon_url(dist.icon_url),
|
|
|
+ icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None,
|
|
|
+ owner_id=dist.owner_id,
|
|
|
+ created_at=dist.created_at,
|
|
|
+ updated_at=dist.updated_at,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+@router.put("/{dist_id}", response_model=ClientDistributionResponse)
|
|
|
+def update_distribution(
|
|
|
+ dist_id: int,
|
|
|
+ data: ClientDistributionUpdate,
|
|
|
+ db: Session = Depends(deps.get_db),
|
|
|
+ current_user: User = Depends(deps.get_current_active_user),
|
|
|
+):
|
|
|
+ """更新分发"""
|
|
|
+ dist = _check_distribution_access(db, dist_id, current_user)
|
|
|
+ if data.name is not None:
|
|
|
+ dist.name = data.name
|
|
|
+ if data.description is not None:
|
|
|
+ dist.description = data.description
|
|
|
+ if data.icon_url is not None:
|
|
|
+ dist.icon_url = data.icon_url
|
|
|
+ db.commit()
|
|
|
+ db.refresh(dist)
|
|
|
+ return ClientDistributionResponse(
|
|
|
+ id=dist.id,
|
|
|
+ name=dist.name,
|
|
|
+ description=dist.description,
|
|
|
+ icon_url=_resolve_icon_url(dist.icon_url),
|
|
|
+ icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None,
|
|
|
+ owner_id=dist.owner_id,
|
|
|
+ created_at=dist.created_at,
|
|
|
+ updated_at=dist.updated_at,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+@router.delete("/{dist_id}")
|
|
|
+def delete_distribution(
|
|
|
+ dist_id: int,
|
|
|
+ db: Session = Depends(deps.get_db),
|
|
|
+ current_user: User = Depends(deps.get_current_active_user),
|
|
|
+):
|
|
|
+ """软删除分发"""
|
|
|
+ dist = _check_distribution_access(db, dist_id, current_user)
|
|
|
+ dist.is_deleted = 1
|
|
|
+ db.commit()
|
|
|
+ return {"message": "已删除"}
|
|
|
+
|
|
|
+
|
|
|
+# ========== Versions ==========
|
|
|
+
|
|
|
+@router.get("/{dist_id}/versions", response_model=ClientVersionList)
|
|
|
+def list_versions(
|
|
|
+ dist_id: int,
|
|
|
+ skip: int = 0,
|
|
|
+ limit: int = 20,
|
|
|
+ db: Session = Depends(deps.get_db),
|
|
|
+ current_user: User = Depends(deps.get_current_active_user),
|
|
|
+):
|
|
|
+ """获取分发下的版本列表"""
|
|
|
+ _check_distribution_access(db, dist_id, current_user)
|
|
|
+ query = db.query(ClientVersion).filter(ClientVersion.distribution_id == dist_id)
|
|
|
+ total = query.count()
|
|
|
+ items = query.order_by(desc(ClientVersion.created_at)).offset(skip).limit(limit).all()
|
|
|
+ return ClientVersionList(total=total, items=items)
|
|
|
+
|
|
|
+
|
|
|
+@router.post("/{dist_id}/versions", response_model=ClientVersionResponse)
|
|
|
+async def create_version(
|
|
|
+ dist_id: int,
|
|
|
+ file: UploadFile = File(...),
|
|
|
+ version_code: str = Form(...),
|
|
|
+ version_name: Optional[str] = Form(None),
|
|
|
+ release_notes: Optional[str] = Form(None),
|
|
|
+ platform: Optional[str] = Form(None),
|
|
|
+ db: Session = Depends(deps.get_db),
|
|
|
+ current_user: User = Depends(deps.get_current_active_user),
|
|
|
+):
|
|
|
+ """创建新版本(上传安装包)"""
|
|
|
+ dist = _check_distribution_access(db, dist_id, current_user)
|
|
|
+
|
|
|
+ # 检查版本号唯一
|
|
|
+ existing = db.query(ClientVersion).filter(
|
|
|
+ ClientVersion.distribution_id == dist_id,
|
|
|
+ ClientVersion.version_code == version_code
|
|
|
+ ).first()
|
|
|
+ if existing:
|
|
|
+ raise HTTPException(status_code=400, detail=f"版本号 {version_code} 已存在")
|
|
|
+
|
|
|
+ content = await file.read()
|
|
|
+ if len(content) > MAX_PACKAGE_SIZE:
|
|
|
+ raise HTTPException(status_code=400, detail="文件大小超过 500MB 限制")
|
|
|
+
|
|
|
+ content_type = file.content_type or "application/octet-stream"
|
|
|
+ if content_type not in ALLOWED_PACKAGE_TYPES and not content_type.startswith("application/"):
|
|
|
+ pass # 放宽:允许 application/*
|
|
|
+
|
|
|
+ try:
|
|
|
+ object_key = minio_storage.upload_distribution_file(
|
|
|
+ file_data=content,
|
|
|
+ filename=file.filename or "package.bin",
|
|
|
+ content_type=content_type,
|
|
|
+ distribution_id=dist_id,
|
|
|
+ )
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Upload failed: {e}")
|
|
|
+ raise HTTPException(status_code=500, detail="文件上传失败")
|
|
|
+
|
|
|
+ version = ClientVersion(
|
|
|
+ distribution_id=dist_id,
|
|
|
+ version_code=version_code,
|
|
|
+ version_name=version_name or version_code,
|
|
|
+ release_notes=release_notes,
|
|
|
+ object_key=object_key,
|
|
|
+ file_size=len(content),
|
|
|
+ platform=platform,
|
|
|
+ )
|
|
|
+ db.add(version)
|
|
|
+ db.commit()
|
|
|
+ db.refresh(version)
|
|
|
+ return version
|
|
|
+
|
|
|
+
|
|
|
+@router.put("/versions/{version_id}", response_model=ClientVersionResponse)
|
|
|
+def update_version(
|
|
|
+ version_id: int,
|
|
|
+ data: ClientVersionUpdate,
|
|
|
+ db: Session = Depends(deps.get_db),
|
|
|
+ current_user: User = Depends(deps.get_current_active_user),
|
|
|
+):
|
|
|
+ """更新版本信息(不含文件)"""
|
|
|
+ version = db.query(ClientVersion).filter(ClientVersion.id == version_id).first()
|
|
|
+ if not version:
|
|
|
+ raise HTTPException(status_code=404, detail="版本不存在")
|
|
|
+ _check_distribution_access(db, version.distribution_id, current_user)
|
|
|
+ if data.version_name is not None:
|
|
|
+ version.version_name = data.version_name
|
|
|
+ if data.release_notes is not None:
|
|
|
+ version.release_notes = data.release_notes
|
|
|
+ if data.platform is not None:
|
|
|
+ version.platform = data.platform
|
|
|
+ db.commit()
|
|
|
+ db.refresh(version)
|
|
|
+ return version
|
|
|
+
|
|
|
+
|
|
|
+@router.delete("/versions/{version_id}")
|
|
|
+def delete_version(
|
|
|
+ version_id: int,
|
|
|
+ db: Session = Depends(deps.get_db),
|
|
|
+ current_user: User = Depends(deps.get_current_active_user),
|
|
|
+):
|
|
|
+ """删除版本"""
|
|
|
+ version = db.query(ClientVersion).filter(ClientVersion.id == version_id).first()
|
|
|
+ if not version:
|
|
|
+ raise HTTPException(status_code=404, detail="版本不存在")
|
|
|
+ _check_distribution_access(db, version.distribution_id, current_user)
|
|
|
+ db.delete(version)
|
|
|
+ db.commit()
|
|
|
+ return {"message": "已删除"}
|