imetting/backend/app/api/endpoints/external_apps.py

519 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from app.core.database import get_db_connection
from app.core.auth import get_current_admin_user
from app.core.response import create_api_response
from app.core.config import BASE_DIR, EXTERNAL_APPS_DIR, ALLOWED_IMAGE_EXTENSIONS, MAX_IMAGE_SIZE
from app.utils.apk_parser import parse_apk_with_androguard
from typing import Optional
from pathlib import Path
from pydantic import BaseModel
import os
import shutil
import json
import uuid
import hashlib
router = APIRouter()
# APK上传配置
ALLOWED_APK_EXTENSIONS = {'.apk'}
MAX_APK_SIZE = 200 * 1024 * 1024 # 200MB
class CreateExternalAppRequest(BaseModel):
app_name: str
app_type: str # 'native' or 'web'
app_info: str # JSON string
icon_url: Optional[str] = None
description: Optional[str] = None
sort_order: int = 0
is_active: bool = True
class UpdateExternalAppRequest(BaseModel):
app_name: Optional[str] = None
app_type: Optional[str] = None
app_info: Optional[str] = None
icon_url: Optional[str] = None
description: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
@router.get("/external-apps", response_model=dict)
async def get_external_apps(
app_type: Optional[str] = None,
is_active: Optional[bool] = None,
page: int = 1,
size: int = 50,
current_user: dict = Depends(get_current_admin_user)
):
"""
获取外部应用列表(管理后台接口)
"""
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
# 构建查询条件
where_clauses = []
params = []
if app_type:
where_clauses.append("app_type = %s")
params.append(app_type)
if is_active is not None:
where_clauses.append("is_active = %s")
params.append(is_active)
where_clause = " AND ".join(where_clauses) if where_clauses else "1=1"
# 获取总数
count_query = f"SELECT COUNT(*) as total FROM external_apps WHERE {where_clause}"
cursor.execute(count_query, params)
total = cursor.fetchone()['total']
# 获取列表数据
offset = (page - 1) * size
list_query = f"""
SELECT ea.*, u.username as creator_username
FROM external_apps ea
LEFT JOIN users u ON ea.created_by = u.user_id
WHERE {where_clause}
ORDER BY ea.sort_order ASC, ea.created_at DESC
LIMIT %s OFFSET %s
"""
cursor.execute(list_query, params + [size, offset])
apps = cursor.fetchall()
# 解析 app_info JSON
for app in apps:
if app.get('app_info'):
try:
app['app_info'] = json.loads(app['app_info'])
except:
app['app_info'] = {}
cursor.close()
return create_api_response(
code="200",
message="获取成功",
data={
"apps": apps,
"total": total,
"page": page,
"size": size
}
)
except Exception as e:
return create_api_response(
code="500",
message=f"获取外部应用列表失败: {str(e)}"
)
@router.get("/external-apps/active", response_model=dict)
async def get_active_external_apps():
"""
获取所有启用的外部应用(公开接口,供客户端调用)
"""
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
query = """
SELECT id, app_name, app_type, app_info, icon_url, description, sort_order
FROM external_apps
WHERE is_active = TRUE
ORDER BY sort_order ASC, created_at DESC
"""
cursor.execute(query)
apps = cursor.fetchall()
# 解析 app_info JSON
for app in apps:
if app.get('app_info'):
try:
app['app_info'] = json.loads(app['app_info'])
except:
app['app_info'] = {}
cursor.close()
# 按类型分组
native_apps = [app for app in apps if app['app_type'] == 'native']
web_apps = [app for app in apps if app['app_type'] == 'web']
return create_api_response(
code="200",
message="获取成功",
data={
"native": native_apps,
"web": web_apps,
"all": apps
}
)
except Exception as e:
return create_api_response(
code="500",
message=f"获取外部应用失败: {str(e)}"
)
@router.post("/external-apps", response_model=dict)
async def create_external_app(
request: CreateExternalAppRequest,
current_user: dict = Depends(get_current_admin_user)
):
"""
创建外部应用
"""
try:
# 验证 app_info 是否为有效JSON
try:
json.loads(request.app_info)
except:
return create_api_response(
code="400",
message="app_info 必须是有效的JSON格式"
)
with get_db_connection() as conn:
cursor = conn.cursor()
query = """
INSERT INTO external_apps
(app_name, app_type, app_info, icon_url, description, sort_order, is_active, created_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
"""
cursor.execute(query, (
request.app_name,
request.app_type,
request.app_info,
request.icon_url,
request.description,
request.sort_order,
request.is_active,
current_user['user_id']
))
app_id = cursor.lastrowid
conn.commit()
cursor.close()
return create_api_response(
code="200",
message="创建成功",
data={"id": app_id}
)
except Exception as e:
return create_api_response(
code="500",
message=f"创建外部应用失败: {str(e)}"
)
@router.put("/external-apps/{app_id}", response_model=dict)
async def update_external_app(
app_id: int,
request: UpdateExternalAppRequest,
current_user: dict = Depends(get_current_admin_user)
):
"""
更新外部应用
"""
try:
# 验证 app_info 是否为有效JSON
if request.app_info:
try:
json.loads(request.app_info)
except:
return create_api_response(
code="400",
message="app_info 必须是有效的JSON格式"
)
with get_db_connection() as conn:
cursor = conn.cursor()
# 构建更新字段
update_fields = []
params = []
if request.app_name is not None:
update_fields.append("app_name = %s")
params.append(request.app_name)
if request.app_type is not None:
update_fields.append("app_type = %s")
params.append(request.app_type)
if request.app_info is not None:
update_fields.append("app_info = %s")
params.append(request.app_info)
if request.icon_url is not None:
update_fields.append("icon_url = %s")
params.append(request.icon_url)
if request.description is not None:
update_fields.append("description = %s")
params.append(request.description)
if request.sort_order is not None:
update_fields.append("sort_order = %s")
params.append(request.sort_order)
if request.is_active is not None:
update_fields.append("is_active = %s")
params.append(request.is_active)
if not update_fields:
return create_api_response(
code="400",
message="没有需要更新的字段"
)
params.append(app_id)
query = f"""
UPDATE external_apps
SET {', '.join(update_fields)}
WHERE id = %s
"""
cursor.execute(query, params)
conn.commit()
cursor.close()
return create_api_response(
code="200",
message="更新成功"
)
except Exception as e:
return create_api_response(
code="500",
message=f"更新外部应用失败: {str(e)}"
)
@router.delete("/external-apps/{app_id}", response_model=dict)
async def delete_external_app(
app_id: int,
current_user: dict = Depends(get_current_admin_user)
):
"""
删除外部应用
"""
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
# 获取应用信息用于删除APK文件
cursor.execute("SELECT * FROM external_apps WHERE id = %s", (app_id,))
app = cursor.fetchone()
if not app:
cursor.close()
return create_api_response(
code="404",
message="应用不存在"
)
# 删除数据库记录
cursor.execute("DELETE FROM external_apps WHERE id = %s", (app_id,))
conn.commit()
# 删除相关文件
files_to_delete = []
# 如果是原生应用添加APK文件到删除列表
if app['app_type'] == 'native' and app.get('app_info'):
try:
app_info = json.loads(app['app_info']) if isinstance(app['app_info'], str) else app['app_info']
apk_url = app_info.get('apk_url', '')
if apk_url and apk_url.startswith('/uploads/'):
apk_path = BASE_DIR / apk_url.lstrip('/')
files_to_delete.append(('APK', apk_path))
except Exception as e:
print(f"Failed to parse app_info for APK deletion: {e}")
# 添加图标文件到删除列表
if app.get('icon_url'):
icon_url = app['icon_url']
if icon_url and icon_url.startswith('/uploads/'):
icon_path = BASE_DIR / icon_url.lstrip('/')
files_to_delete.append(('Icon', icon_path))
# 执行文件删除
for file_type, file_path in files_to_delete:
try:
if file_path.exists():
os.remove(file_path)
print(f"Deleted {file_type} file: {file_path}")
except Exception as e:
print(f"Failed to delete {file_type} file: {e}")
cursor.close()
return create_api_response(
code="200",
message="删除成功"
)
except Exception as e:
return create_api_response(
code="500",
message=f"删除外部应用失败: {str(e)}"
)
@router.post("/external-apps/upload-apk", response_model=dict)
async def upload_apk(
apk_file: UploadFile = File(...),
current_user: dict = Depends(get_current_admin_user)
):
"""
上传APK文件并解析信息
"""
try:
# 验证文件类型
file_extension = os.path.splitext(apk_file.filename)[1].lower()
if file_extension not in ALLOWED_APK_EXTENSIONS:
return create_api_response(
code="400",
message=f"不支持的文件类型。仅支持: {', '.join(ALLOWED_APK_EXTENSIONS)}"
)
# 验证文件大小
apk_file.file.seek(0, 2) # 移动到文件末尾
file_size = apk_file.file.tell()
apk_file.file.seek(0) # 重置到文件开头
if file_size > MAX_APK_SIZE:
return create_api_response(
code="400",
message=f"文件大小超过 {MAX_APK_SIZE // (1024 * 1024)}MB 限制"
)
# 使用原始文件名
original_filename = apk_file.filename
file_path = EXTERNAL_APPS_DIR / original_filename
# 如果同名文件已存在,先删除
if file_path.exists():
try:
os.remove(file_path)
print(f"删除已存在的同名文件: {file_path}")
except Exception as e:
print(f"删除同名文件失败: {e}")
# 保存文件
with open(file_path, "wb") as buffer:
shutil.copyfileobj(apk_file.file, buffer)
# 计算MD5
md5_hash = hashlib.md5()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
md5_hash.update(chunk)
apk_md5 = md5_hash.hexdigest()
# 解析APK
try:
apk_info = parse_apk_with_androguard(str(file_path))
print(f"APK解析成功: {apk_info}")
except Exception as e:
# 删除已上传的文件
if file_path.exists():
os.remove(file_path)
return create_api_response(
code="400",
message=f"APK解析失败: {str(e)}"
)
# 计算相对路径
relative_path = file_path.relative_to(EXTERNAL_APPS_DIR.parent.parent)
# 返回解析结果
return create_api_response(
code="200",
message="APK上传并解析成功",
data={
"apk_url": "/" + str(relative_path).replace("\\", "/"),
"apk_size": file_size,
"apk_md5": apk_md5,
"package_name": apk_info.get('package_name'),
"version_name": apk_info.get('version_name'),
"version_code": apk_info.get('version_code'),
"app_name": apk_info.get('app_name'),
"min_sdk_version": apk_info.get('min_sdk_version'),
"target_sdk_version": apk_info.get('target_sdk_version')
}
)
except Exception as e:
return create_api_response(
code="500",
message=f"上传APK失败: {str(e)}"
)
@router.post("/external-apps/upload-icon", response_model=dict)
async def upload_icon(
icon_file: UploadFile = File(...),
current_user: dict = Depends(get_current_admin_user)
):
"""
上传应用图标
"""
try:
# 验证文件类型
file_extension = os.path.splitext(icon_file.filename)[1].lower()
if file_extension not in ALLOWED_IMAGE_EXTENSIONS:
return create_api_response(
code="400",
message=f"不支持的文件类型。仅支持: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}"
)
# 验证文件大小
icon_file.file.seek(0, 2)
file_size = icon_file.file.tell()
icon_file.file.seek(0)
if file_size > MAX_IMAGE_SIZE:
return create_api_response(
code="400",
message=f"文件大小超过 {MAX_IMAGE_SIZE // (1024 * 1024)}MB 限制"
)
# 生成唯一文件名图标使用UUID避免冲突
unique_filename = f"icon_{uuid.uuid4()}{file_extension}"
file_path = EXTERNAL_APPS_DIR / unique_filename
# 保存文件
with open(file_path, "wb") as buffer:
shutil.copyfileobj(icon_file.file, buffer)
# 计算相对路径
relative_path = file_path.relative_to(EXTERNAL_APPS_DIR.parent.parent)
return create_api_response(
code="200",
message="图标上传成功",
data={
"icon_url": "/" + str(relative_path).replace("\\", "/"),
"file_size": file_size
}
)
except Exception as e:
return create_api_response(
code="500",
message=f"上传图标失败: {str(e)}"
)