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": "已删除"}