Files
ns2.0/backend/app/api/apps.py
Nvex 720402ffe7 feat: NEXT Store 2.0 重大更新 - 完整重构前后端
🎉 主要更新:

后端:
- 全新华为应用市场爬虫系统
- 三表分离数据库设计 (app_info, app_metrics, app_rating)
- 完整的API接口 (搜索、分类、热门、上新等)
- 元服务自动识别和分类
- 智能Token管理和数据处理
- 修复热门应用重复显示问题

前端:
- 全新首页设计 (今日上架、热门应用)
- 应用页面 (彩色分类磁贴、智能图标匹配)
- 今日上新页面 (日期切换)
- 热门应用页面 (卡片布局)
- 应用详情页面 (完整信息展示)
- Apple风格搜索栏
- Footer组件
- 底部导航栏优化 (4个导航项)
- 骨架屏加载效果
- FontAwesome图标集成

UI/UX:
- 统一浅色背景 (#F5F5F7)
- 流畅的过渡动画
- 响应式设计
- 毛玻璃效果

文档:
- CHANGELOG.md - 完整更新日志
- QUICKSTART.md - 快速开始
- 多个技术文档和使用指南

版本: v2.0.0
2025-10-25 21:20:32 +08:00

533 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)