🎉 主要更新:
后端:
- 全新华为应用市场爬虫系统
- 三表分离数据库设计 (app_info, app_metrics, app_rating)
- 完整的API接口 (搜索、分类、热门、上新等)
- 元服务自动识别和分类
- 智能Token管理和数据处理
- 修复热门应用重复显示问题
前端:
- 全新首页设计 (今日上架、热门应用)
- 应用页面 (彩色分类磁贴、智能图标匹配)
- 今日上新页面 (日期切换)
- 热门应用页面 (卡片布局)
- 应用详情页面 (完整信息展示)
- Apple风格搜索栏
- Footer组件
- 底部导航栏优化 (4个导航项)
- 骨架屏加载效果
- FontAwesome图标集成
UI/UX:
- 统一浅色背景 (#F5F5F7)
- 流畅的过渡动画
- 响应式设计
- 毛玻璃效果
文档:
- CHANGELOG.md - 完整更新日志
- QUICKSTART.md - 快速开始
- 多个技术文档和使用指南
版本: v2.0.0
533 lines
19 KiB
Python
533 lines
19 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, func, and_, or_
|
||
from datetime import datetime, timedelta
|
||
from typing import Optional
|
||
from app.database import get_db
|
||
from app.models import AppInfo, AppMetrics, AppRating
|
||
from app.schemas import ApiResponse
|
||
from app.crawler.huawei_api import HuaweiAPI
|
||
from app.crawler.data_processor import DataProcessor
|
||
|
||
router = APIRouter(prefix="/apps", tags=["应用"])
|
||
|
||
@router.get("/fetch/{pkg_name}")
|
||
async def fetch_app_by_pkg_name(
|
||
pkg_name: str,
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""通过包名从华为API获取应用信息并保存"""
|
||
api = HuaweiAPI()
|
||
try:
|
||
# 从华为API获取数据
|
||
print(f"正在获取应用信息: {pkg_name}")
|
||
app_data = await api.get_app_info(pkg_name=pkg_name)
|
||
|
||
# 获取评分数据
|
||
rating_data = await api.get_app_rating(app_data['appId'])
|
||
|
||
# 保存到数据库
|
||
processor = DataProcessor(db)
|
||
new_info, new_metric, new_rating = await processor.save_app_data(
|
||
app_data, rating_data
|
||
)
|
||
|
||
return ApiResponse(
|
||
success=True,
|
||
data={
|
||
"app_id": app_data['appId'],
|
||
"name": app_data['name'],
|
||
"pkg_name": app_data['pkgName'],
|
||
"new_info": new_info,
|
||
"new_metric": new_metric,
|
||
"new_rating": new_rating,
|
||
"message": "应用信息获取成功"
|
||
}
|
||
)
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"获取应用信息失败: {str(e)}")
|
||
finally:
|
||
await api.close()
|
||
|
||
@router.get("/search")
|
||
async def search_apps(
|
||
q: str = Query(..., min_length=1),
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(20, le=100),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""搜索应用"""
|
||
subquery = (
|
||
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
|
||
.group_by(AppMetrics.app_id)
|
||
.subquery()
|
||
)
|
||
|
||
query = (
|
||
select(AppInfo, AppMetrics, AppRating)
|
||
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
||
.outerjoin(AppRating, AppInfo.app_id == AppRating.app_id)
|
||
.join(subquery, and_(
|
||
AppMetrics.app_id == subquery.c.app_id,
|
||
AppMetrics.created_at == subquery.c.max_created_at
|
||
))
|
||
.where(or_(
|
||
AppInfo.name.like(f"%{q}%"),
|
||
AppInfo.pkg_name.like(f"%{q}%"),
|
||
AppInfo.developer_name.like(f"%{q}%")
|
||
))
|
||
.order_by(AppMetrics.download_count.desc())
|
||
)
|
||
|
||
count_query = select(func.count(AppInfo.app_id)).where(or_(
|
||
AppInfo.name.like(f"%{q}%"),
|
||
AppInfo.pkg_name.like(f"%{q}%"),
|
||
AppInfo.developer_name.like(f"%{q}%")
|
||
))
|
||
|
||
total_result = await db.execute(count_query)
|
||
total = total_result.scalar()
|
||
|
||
offset = (page - 1) * page_size
|
||
query = query.offset(offset).limit(page_size)
|
||
|
||
result = await db.execute(query)
|
||
rows = result.all()
|
||
|
||
data = [{
|
||
"app_id": row[0].app_id,
|
||
"name": row[0].name,
|
||
"pkg_name": row[0].pkg_name,
|
||
"developer_name": row[0].developer_name,
|
||
"kind_name": row[0].kind_name,
|
||
"icon_url": row[0].icon_url,
|
||
"brief_desc": row[0].brief_desc,
|
||
"download_count": row[1].download_count if len(row) > 1 else 0,
|
||
"version": row[1].version if len(row) > 1 else "",
|
||
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0,
|
||
"listed_at": row[0].listed_at.isoformat()
|
||
} for row in rows]
|
||
|
||
return ApiResponse(success=True, data=data, total=total, limit=page_size)
|
||
|
||
@router.get("/category/{category}")
|
||
async def get_apps_by_category(
|
||
category: str,
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(20, le=100),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""按分类获取应用"""
|
||
subquery = (
|
||
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
|
||
.group_by(AppMetrics.app_id)
|
||
.subquery()
|
||
)
|
||
|
||
# 构建基础查询
|
||
query = (
|
||
select(AppInfo, AppMetrics, AppRating)
|
||
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
||
.outerjoin(AppRating, AppInfo.app_id == AppRating.app_id)
|
||
.join(subquery, and_(
|
||
AppMetrics.app_id == subquery.c.app_id,
|
||
AppMetrics.created_at == subquery.c.max_created_at
|
||
))
|
||
)
|
||
|
||
# 如果是元服务分类,只显示元服务(packing_type = 1)
|
||
if category == "元服务":
|
||
query = query.where(AppInfo.packing_type == 1)
|
||
else:
|
||
# 其他分类排除元服务,并按kind_name筛选
|
||
query = query.where(and_(
|
||
AppInfo.kind_name == category,
|
||
or_(AppInfo.packing_type != 1, AppInfo.packing_type.is_(None))
|
||
))
|
||
|
||
query = query.order_by(AppMetrics.download_count.desc())
|
||
)
|
||
|
||
count_query = select(func.count(AppInfo.app_id)).where(AppInfo.kind_name == category)
|
||
total_result = await db.execute(count_query)
|
||
total = total_result.scalar()
|
||
|
||
offset = (page - 1) * page_size
|
||
query = query.offset(offset).limit(page_size)
|
||
|
||
result = await db.execute(query)
|
||
rows = result.all()
|
||
|
||
data = [{
|
||
"app_id": row[0].app_id,
|
||
"name": row[0].name,
|
||
"pkg_name": row[0].pkg_name,
|
||
"developer_name": row[0].developer_name,
|
||
"kind_name": row[0].kind_name,
|
||
"icon_url": row[0].icon_url,
|
||
"brief_desc": row[0].brief_desc,
|
||
"download_count": row[1].download_count if len(row) > 1 else 0,
|
||
"version": row[1].version if len(row) > 1 else "",
|
||
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0,
|
||
"listed_at": row[0].listed_at.isoformat()
|
||
} for row in rows]
|
||
|
||
return ApiResponse(success=True, data=data, total=total, limit=page_size)
|
||
|
||
@router.get("/categories")
|
||
async def get_categories(db: AsyncSession = Depends(get_db)):
|
||
"""获取所有分类"""
|
||
# 获取元服务数量
|
||
atomic_service_result = await db.execute(
|
||
select(func.count(AppInfo.app_id))
|
||
.where(AppInfo.packing_type == 1)
|
||
)
|
||
atomic_service_count = atomic_service_result.scalar()
|
||
|
||
# 获取其他分类(排除元服务)
|
||
result = await db.execute(
|
||
select(AppInfo.kind_name, func.count(AppInfo.app_id).label('count'))
|
||
.where(or_(AppInfo.packing_type != 1, AppInfo.packing_type.is_(None)))
|
||
.group_by(AppInfo.kind_name)
|
||
.order_by(func.count(AppInfo.app_id).desc())
|
||
)
|
||
rows = result.all()
|
||
|
||
data = []
|
||
|
||
# 如果有元服务,添加到列表首位
|
||
if atomic_service_count > 0:
|
||
data.append({"name": "元服务", "count": atomic_service_count})
|
||
|
||
# 添加其他分类
|
||
data.extend([{"name": row[0], "count": row[1]} for row in rows])
|
||
|
||
return ApiResponse(success=True, data=data)
|
||
|
||
@router.get("/by-date")
|
||
async def get_apps_by_date(
|
||
date: str = Query(..., description="日期格式: YYYY-MM-DD"),
|
||
page_size: int = Query(100, le=100),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""获取指定日期上架的应用"""
|
||
try:
|
||
from datetime import datetime, time
|
||
|
||
# 解析日期字符串
|
||
target_date = datetime.strptime(date, '%Y-%m-%d')
|
||
date_start = datetime.combine(target_date, time.min)
|
||
date_end = datetime.combine(target_date, time.max)
|
||
|
||
# 获取最新的指标记录
|
||
subquery = (
|
||
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
|
||
.group_by(AppMetrics.app_id)
|
||
.subquery()
|
||
)
|
||
|
||
# 查询指定日期上架的应用
|
||
query = (
|
||
select(AppInfo, AppMetrics, AppRating)
|
||
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
||
.outerjoin(AppRating, AppInfo.app_id == AppRating.app_id)
|
||
.join(subquery, and_(
|
||
AppMetrics.app_id == subquery.c.app_id,
|
||
AppMetrics.created_at == subquery.c.max_created_at
|
||
))
|
||
.where(and_(
|
||
AppInfo.listed_at >= date_start,
|
||
AppInfo.listed_at <= date_end
|
||
))
|
||
.order_by(AppInfo.listed_at.desc())
|
||
.limit(page_size)
|
||
)
|
||
|
||
result = await db.execute(query)
|
||
rows = result.all()
|
||
|
||
data = [{
|
||
"app_id": row[0].app_id,
|
||
"name": row[0].name,
|
||
"pkg_name": row[0].pkg_name,
|
||
"developer_name": row[0].developer_name,
|
||
"kind_name": row[0].kind_name,
|
||
"icon_url": row[0].icon_url,
|
||
"brief_desc": row[0].brief_desc,
|
||
"download_count": row[1].download_count if len(row) > 1 and row[1] else 0,
|
||
"version": row[1].version if len(row) > 1 and row[1] else "",
|
||
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0.0,
|
||
"total_rating_count": row[2].total_rating_count if len(row) > 2 and row[2] else 0,
|
||
"listed_at": row[0].listed_at.isoformat() if row[0].listed_at else ""
|
||
} for row in rows]
|
||
|
||
return ApiResponse(success=True, data=data, total=len(data))
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=f"日期格式错误: {str(e)}")
|
||
except Exception as e:
|
||
print(f"Error in get_apps_by_date: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return ApiResponse(success=True, data=[], total=0)
|
||
|
||
@router.get("/today")
|
||
async def get_today_apps(
|
||
page_size: int = Query(100, le=100),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""获取今日上架应用(根据 listed_at 字段判断是否为今天上架)"""
|
||
try:
|
||
# 获取今天的日期范围(00:00:00 到 23:59:59)
|
||
from datetime import datetime, time
|
||
today_start = datetime.combine(datetime.today(), time.min)
|
||
today_end = datetime.combine(datetime.today(), time.max)
|
||
|
||
# 获取最新的指标记录
|
||
subquery = (
|
||
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
|
||
.group_by(AppMetrics.app_id)
|
||
.subquery()
|
||
)
|
||
|
||
# 查询今天上架的应用(根据 listed_at 字段)
|
||
query = (
|
||
select(AppInfo, AppMetrics, AppRating)
|
||
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
||
.outerjoin(AppRating, AppInfo.app_id == AppRating.app_id)
|
||
.join(subquery, and_(
|
||
AppMetrics.app_id == subquery.c.app_id,
|
||
AppMetrics.created_at == subquery.c.max_created_at
|
||
))
|
||
.where(and_(
|
||
AppInfo.listed_at >= today_start,
|
||
AppInfo.listed_at <= today_end
|
||
))
|
||
.order_by(AppInfo.listed_at.desc())
|
||
.limit(page_size)
|
||
)
|
||
|
||
result = await db.execute(query)
|
||
rows = result.all()
|
||
|
||
data = [{
|
||
"app_id": row[0].app_id,
|
||
"name": row[0].name,
|
||
"pkg_name": row[0].pkg_name,
|
||
"developer_name": row[0].developer_name,
|
||
"kind_name": row[0].kind_name,
|
||
"icon_url": row[0].icon_url,
|
||
"brief_desc": row[0].brief_desc,
|
||
"download_count": row[1].download_count if len(row) > 1 and row[1] else 0,
|
||
"version": row[1].version if len(row) > 1 and row[1] else "",
|
||
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0.0,
|
||
"total_rating_count": row[2].total_rating_count if len(row) > 2 and row[2] else 0,
|
||
"listed_at": row[0].listed_at.isoformat() if row[0].listed_at else ""
|
||
} for row in rows]
|
||
|
||
return ApiResponse(success=True, data=data, total=len(data))
|
||
except Exception as e:
|
||
print(f"Error in get_today_apps: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
# 返回空列表而不是抛出错误
|
||
return ApiResponse(success=True, data=[], total=0)
|
||
|
||
@router.get("/top-downloads")
|
||
async def get_top_downloads(
|
||
limit: int = Query(100, le=100),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""热门应用Top100"""
|
||
# 最新的指标记录
|
||
subquery_metric = (
|
||
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
|
||
.group_by(AppMetrics.app_id)
|
||
.subquery()
|
||
)
|
||
|
||
# 最新的评分记录
|
||
subquery_rating = (
|
||
select(AppRating.app_id, func.max(AppRating.created_at).label('max_rating_created_at'))
|
||
.group_by(AppRating.app_id)
|
||
.subquery()
|
||
)
|
||
|
||
query = (
|
||
select(AppInfo, AppMetrics, AppRating)
|
||
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
||
.join(subquery_metric, and_(
|
||
AppMetrics.app_id == subquery_metric.c.app_id,
|
||
AppMetrics.created_at == subquery_metric.c.max_created_at
|
||
))
|
||
.outerjoin(subquery_rating, AppInfo.app_id == subquery_rating.c.app_id)
|
||
.outerjoin(AppRating, and_(
|
||
AppInfo.app_id == AppRating.app_id,
|
||
AppRating.created_at == subquery_rating.c.max_rating_created_at
|
||
))
|
||
.order_by(AppMetrics.download_count.desc())
|
||
.limit(limit)
|
||
)
|
||
|
||
result = await db.execute(query)
|
||
rows = result.all()
|
||
|
||
data = [{
|
||
"app_id": row[0].app_id,
|
||
"name": row[0].name,
|
||
"pkg_name": row[0].pkg_name,
|
||
"developer_name": row[0].developer_name,
|
||
"kind_name": row[0].kind_name,
|
||
"icon_url": row[0].icon_url,
|
||
"brief_desc": row[0].brief_desc,
|
||
"download_count": row[1].download_count if len(row) > 1 else 0,
|
||
"version": row[1].version if len(row) > 1 else "",
|
||
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0
|
||
} for row in rows]
|
||
|
||
return ApiResponse(success=True, data=data, total=len(data))
|
||
|
||
@router.get("/top-ratings")
|
||
async def get_top_ratings(
|
||
limit: int = Query(100, le=100),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""评分Top100"""
|
||
subquery_metric = (
|
||
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
|
||
.group_by(AppMetrics.app_id)
|
||
.subquery()
|
||
)
|
||
|
||
subquery_rating = (
|
||
select(AppRating.app_id, func.max(AppRating.created_at).label('max_created_at'))
|
||
.group_by(AppRating.app_id)
|
||
.subquery()
|
||
)
|
||
|
||
query = (
|
||
select(AppInfo, AppMetrics, AppRating)
|
||
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
||
.join(AppRating, AppInfo.app_id == AppRating.app_id)
|
||
.join(subquery_metric, and_(
|
||
AppMetrics.app_id == subquery_metric.c.app_id,
|
||
AppMetrics.created_at == subquery_metric.c.max_created_at
|
||
))
|
||
.join(subquery_rating, and_(
|
||
AppRating.app_id == subquery_rating.c.app_id,
|
||
AppRating.created_at == subquery_rating.c.max_created_at
|
||
))
|
||
.where(AppRating.total_rating_count >= 100)
|
||
.order_by(AppRating.average_rating.desc())
|
||
.limit(limit)
|
||
)
|
||
|
||
result = await db.execute(query)
|
||
rows = result.all()
|
||
|
||
data = [{
|
||
"app_id": row[0].app_id,
|
||
"name": row[0].name,
|
||
"pkg_name": row[0].pkg_name,
|
||
"developer_name": row[0].developer_name,
|
||
"kind_name": row[0].kind_name,
|
||
"icon_url": row[0].icon_url,
|
||
"brief_desc": row[0].brief_desc,
|
||
"download_count": row[1].download_count if len(row) > 1 else 0,
|
||
"version": row[1].version if len(row) > 1 else "",
|
||
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0,
|
||
"total_rating_count": row[2].total_rating_count if len(row) > 2 and row[2] else 0
|
||
} for row in rows]
|
||
|
||
return ApiResponse(success=True, data=data, total=len(data))
|
||
|
||
@router.get("/{app_id}")
|
||
async def get_app_detail(app_id: str, db: AsyncSession = Depends(get_db)):
|
||
"""获取应用详情"""
|
||
subquery = (
|
||
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
|
||
.where(AppMetrics.app_id == app_id)
|
||
.group_by(AppMetrics.app_id)
|
||
.subquery()
|
||
)
|
||
|
||
query = (
|
||
select(AppInfo, AppMetrics, AppRating)
|
||
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
||
.outerjoin(AppRating, AppInfo.app_id == AppRating.app_id)
|
||
.join(subquery, and_(
|
||
AppMetrics.app_id == subquery.c.app_id,
|
||
AppMetrics.created_at == subquery.c.max_created_at
|
||
))
|
||
.where(AppInfo.app_id == app_id)
|
||
)
|
||
|
||
result = await db.execute(query)
|
||
row = result.first()
|
||
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="应用不存在")
|
||
|
||
data = {
|
||
# 基本信息
|
||
"app_id": row[0].app_id,
|
||
"name": row[0].name,
|
||
"pkg_name": row[0].pkg_name,
|
||
|
||
# 开发者信息
|
||
"developer_name": row[0].developer_name,
|
||
"dev_id": row[0].dev_id,
|
||
"supplier": row[0].supplier,
|
||
|
||
# 分类信息
|
||
"kind_name": row[0].kind_name,
|
||
"kind_id": row[0].kind_id,
|
||
"tag_name": row[0].tag_name,
|
||
|
||
# 展示信息
|
||
"icon_url": row[0].icon_url,
|
||
"brief_desc": row[0].brief_desc,
|
||
"description": row[0].description,
|
||
|
||
# 隐私和政策
|
||
"privacy_url": row[0].privacy_url,
|
||
|
||
# 价格和支付
|
||
"is_pay": row[0].is_pay,
|
||
"price": row[0].price,
|
||
|
||
# 时间信息
|
||
"listed_at": row[0].listed_at.isoformat(),
|
||
|
||
# 设备支持
|
||
"main_device_codes": row[0].main_device_codes or [],
|
||
|
||
# SDK信息
|
||
"target_sdk": row[0].target_sdk,
|
||
"min_sdk": row[0].min_sdk,
|
||
"compile_sdk_version": row[0].compile_sdk_version,
|
||
"min_hmos_api_level": row[0].min_hmos_api_level,
|
||
"api_release_type": row[0].api_release_type,
|
||
|
||
# 其他信息
|
||
"ctype": row[0].ctype,
|
||
"app_level": row[0].app_level,
|
||
"packing_type": row[0].packing_type,
|
||
|
||
# 版本和指标信息
|
||
"download_count": row[1].download_count if len(row) > 1 else 0,
|
||
"version": row[1].version if len(row) > 1 else "",
|
||
"size_bytes": row[1].size_bytes if len(row) > 1 else 0,
|
||
|
||
# 评分信息
|
||
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0,
|
||
"total_rating_count": row[2].total_rating_count if len(row) > 2 and row[2] else 0,
|
||
"star_1_count": row[2].star_1_count if len(row) > 2 and row[2] else 0,
|
||
"star_2_count": row[2].star_2_count if len(row) > 2 and row[2] else 0,
|
||
"star_3_count": row[2].star_3_count if len(row) > 2 and row[2] else 0,
|
||
"star_4_count": row[2].star_4_count if len(row) > 2 and row[2] else 0,
|
||
"star_5_count": row[2].star_5_count if len(row) > 2 and row[2] else 0
|
||
}
|
||
|
||
return ApiResponse(success=True, data=data)
|