""" 市场集市扩展 API(管理员) Phase 3.7: Market Expansion - Analytics, Featured, Category Management """ from fastapi import APIRouter, Depends, HTTPException, Query, Body from sqlalchemy.orm import Session from sqlalchemy import or_, func, desc, case, text from pydantic import BaseModel from typing import Optional, List, Any import json import uuid from datetime import datetime, timedelta from ...database import get_db, SessionLocal from ...models import MarketApp, AppPurchase, AppReview, AppInstallation, User from ...auth import get_current_admin from ...middleware.log_service import LogService router = APIRouter() # App categories APP_CATEGORIES = { "coding": "编程开发", "assistant": "智能助手", "tools": "效率工具", "writing": "内容创作", "education": "教育培训", "marketing": "营销推广", "data": "数据分析", "design": "设计创意", "business": "企业管理", "other": "其他", } class FeaturedUpdateRequest(BaseModel): featured: bool featured_order: int = 0 class CategoryUpdateRequest(BaseModel): category: str tags: Optional[str] = None class AppEditRequest(BaseModel): name: Optional[str] = None description: Optional[str] = None icon: Optional[str] = None tutorial: Optional[str] = None category: Optional[str] = None tags: Optional[str] = None price: Optional[float] = None is_member_free: Optional[bool] = None featured: Optional[bool] = None featured_order: Optional[int] = None status: Optional[str] = None def _execute_raw(db, sql, params=None): """Execute raw SQL and return results.""" result = db.execute(text(sql), params or {}) try: rows = result.fetchall() return [dict(row._mapping) if hasattr(row, '_mapping') else dict(row) for row in rows] except: return [] def _app_to_dict(app, include_content=False): """Convert MarketApp to dict.""" d = { "id": app.id, "name": app.name, "type": app.type, "category": getattr(app, 'category', 'other'), "tags": getattr(app, 'tags', None), "source": app.source, "publisher_name": app.publisher_name, "description": app.description, "icon": app.icon, "price": float(app.price) if app.price else 0.0, "is_member_free": app.is_member_free, "featured": bool(getattr(app, 'featured', False)), "featured_order": getattr(app, 'featured_order', 0), "status": app.status, "rating_avg": float(app.rating_avg) if app.rating_avg else 0.0, "review_count": app.review_count, "install_count": app.install_count, "version": getattr(app, 'version', '1.0.0'), "created_at": app.created_at.strftime('%Y-%m-%d %H:%M:%S') if app.created_at else None, "updated_at": app.updated_at.strftime('%Y-%m-%d %H:%M:%S') if app.updated_at else None, } if include_content: d["tutorial"] = app.tutorial d["skill_content"] = app.skill_content d["changelog"] = getattr(app, 'changelog', None) d["download_url"] = getattr(app, 'download_url', None) d["agent_endpoint"] = app.agent_endpoint d["mcp_transport"] = app.mcp_transport return d # ============================================================ # Analytics endpoints # ============================================================ @router.get("/analytics/overview", summary="市场总览数据") async def get_market_overview( admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取市场总览统计数据""" # Total apps by status status_stats = _execute_raw(db, """ SELECT status, COUNT(*) as cnt FROM market_apps GROUP BY status """) status_map = {r["status"]: r["cnt"] for r in status_stats} # Total installs install_total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM app_installations") total_installs = install_total[0]["cnt"] if install_total else 0 # Total purchases and revenue purchase_stats = _execute_raw(db, """ SELECT COUNT(*) as cnt, COALESCE(SUM(price), 0) as revenue FROM app_purchases WHERE status = 'purchased' """) total_purchases = purchase_stats[0]["cnt"] if purchase_stats else 0 total_revenue = float(purchase_stats[0]["revenue"]) if purchase_stats else 0.0 # Total reviews review_total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM app_reviews") total_reviews = review_total[0]["cnt"] if review_total else 0 # Avg rating avg_rating = _execute_raw(db, "SELECT COALESCE(AVG(rating), 0) as avg FROM app_reviews") overall_rating = float(avg_rating[0]["avg"]) if avg_rating else 0.0 # Apps by type type_stats = _execute_raw(db, """ SELECT type, COUNT(*) as cnt FROM market_apps WHERE status = 'published' GROUP BY type """) # Apps by category category_stats = _execute_raw(db, """ SELECT COALESCE(category, 'other') as category, COUNT(*) as cnt FROM market_apps WHERE status = 'published' GROUP BY category ORDER BY cnt DESC """) # Recent 7 days trend trend = [] for i in range(6, -1, -1): date = (datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d') next_date = (datetime.now() - timedelta(days=i-1)).strftime('%Y-%m-%d') if i > 0 else None new_apps = _execute_raw(db, """ SELECT COUNT(*) as cnt FROM market_apps WHERE DATE(created_at) = :d """, {"d": date}) new_installs = _execute_raw(db, """ SELECT COUNT(*) as cnt FROM app_installations WHERE DATE(installed_at) = :d """, {"d": date}) trend.append({ "date": date, "new_apps": new_apps[0]["cnt"] if new_apps else 0, "new_installs": new_installs[0]["cnt"] if new_installs else 0, }) return { "code": 0, "data": { "total_apps": sum(status_map.values()), "published": status_map.get("published", 0), "pending": status_map.get("pending", 0), "removed": status_map.get("removed", 0), "total_installs": total_installs, "total_purchases": total_purchases, "total_revenue": total_revenue, "total_reviews": total_reviews, "overall_rating": round(overall_rating, 2), "by_type": {r["type"]: r["cnt"] for r in type_stats}, "by_category": {r["category"]: r["cnt"] for r in category_stats}, "trend_7d": trend, } } @router.get("/analytics/top-apps", summary="热门应用排行") async def get_top_apps( metric: str = Query("installs", regex="^(installs|rating|revenue|reviews)$"), limit: int = Query(10, le=50), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取热门应用排行""" if metric == "installs": order = desc(MarketApp.install_count) elif metric == "rating": order = desc(MarketApp.rating_avg) elif metric == "reviews": order = desc(MarketApp.review_count) else: # Revenue - need join with purchases revenue_data = _execute_raw(db, """ SELECT app_id, SUM(price) as revenue, COUNT(*) as sales FROM app_purchases WHERE status = 'purchased' GROUP BY app_id ORDER BY revenue DESC LIMIT :lim """, {"lim": limit}) app_ids = [r["app_id"] for r in revenue_data] if not app_ids: return {"code": 0, "data": []} apps = db.query(MarketApp).filter(MarketApp.id.in_(app_ids)).all() app_map = {a.id: a for a in apps} result = [] for r in revenue_data: app = app_map.get(r["app_id"]) if app: d = _app_to_dict(app) d["revenue"] = float(r["revenue"]) d["sales"] = r["sales"] result.append(d) return {"code": 0, "data": result} apps = db.query(MarketApp).filter( MarketApp.status == "published" ).order_by(order).limit(limit).all() return { "code": 0, "data": [_app_to_dict(app) for app in apps] } @router.get("/analytics/recent-reviews", summary="最新评价") async def get_recent_reviews( limit: int = Query(20, le=100), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取最新用户评价""" reviews = _execute_raw(db, """ SELECT r.id, r.rating, r.content, r.created_at, u.username, u.avatar, a.name as app_name, a.icon as app_icon FROM app_reviews r LEFT JOIN users u ON r.user_id = u.id LEFT JOIN market_apps a ON r.app_id = a.id ORDER BY r.created_at DESC LIMIT :lim """, {"lim": limit}) return { "code": 0, "data": [{ "id": r["id"], "rating": r["rating"], "content": r["content"], "username": r["username"] or "Anonymous", "avatar": r["avatar"], "app_name": r["app_name"] or "Unknown", "app_icon": r["app_icon"], "created_at": r["created_at"].strftime('%Y-%m-%d %H:%M:%S') if r["created_at"] else None, } for r in reviews] } # ============================================================ # App management endpoints # ============================================================ @router.get("/apps", summary="获取所有市场应用") async def list_market_apps( status: str = Query(None), app_type: str = Query(None), category: str = Query(None), featured: Optional[bool] = Query(None), keyword: str = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取所有市场应用列表(支持筛选)""" query = db.query(MarketApp) if status: query = query.filter(MarketApp.status == status) if app_type: query = query.filter(MarketApp.type == app_type) if category: query = query.filter(text("category = :cat").bindparams(cat=category)) if featured is not None: query = query.filter(text("featured = :feat").bindparams(feat=int(featured))) if keyword: query = query.filter(or_( MarketApp.name.contains(keyword), MarketApp.description.contains(keyword), )) total = query.count() apps = query.order_by(desc(MarketApp.created_at)).offset( (page - 1) * page_size ).limit(page_size).all() return { "code": 0, "data": { "total": total, "page": page, "page_size": page_size, "items": [_app_to_dict(app) for app in apps], } } @router.get("/apps/pending", summary="获取待审核应用") async def get_pending_apps( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取待审核应用列表""" query = db.query(MarketApp).filter(MarketApp.status == "pending") total = query.count() apps = query.order_by(MarketApp.created_at.desc()).offset( (page - 1) * page_size ).limit(page_size).all() return { "code": 0, "data": { "total": total, "page": page, "page_size": page_size, "items": [_app_to_dict(app, include_content=True) for app in apps], } } @router.put("/apps/{app_id}/approve", summary="审核通过") async def approve_app( app_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """审核通过应用""" app = db.query(MarketApp).filter(MarketApp.id == app_id).first() if not app: raise HTTPException(status_code=404, detail="应用不存在") app.status = "published" app.updated_at = datetime.now() db.commit() LogService.log_action(db, "admin", admin_user["id"], "approve_market_app", f"Approved app: {app.name}") return {"code": 0, "message": "审核通过", "data": _app_to_dict(app)} @router.put("/apps/{app_id}/reject", summary="审核拒绝") async def reject_app( app_id: str, reason: str = Query("", max_length=500), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """审核拒绝应用""" app = db.query(MarketApp).filter(MarketApp.id == app_id).first() if not app: raise HTTPException(status_code=404, detail="应用不存在") app.status = "removed" app.updated_at = datetime.now() db.commit() LogService.log_action(db, "admin", admin_user["id"], "reject_market_app", f"Rejected app: {app.name}, reason: {reason}") return {"code": 0, "message": "已拒绝", "data": _app_to_dict(app)} @router.put("/apps/{app_id}", summary="编辑应用") async def update_app( app_id: str, req: AppEditRequest, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """管理员编辑应用信息""" app = db.query(MarketApp).filter(MarketApp.id == app_id).first() if not app: raise HTTPException(status_code=404, detail="应用不存在") if req.name is not None: app.name = req.name if req.description is not None: app.description = req.description if req.icon is not None: app.icon = req.icon if req.tutorial is not None: app.tutorial = req.tutorial if req.category is not None: # Use raw SQL for new columns db.execute(text("UPDATE market_apps SET category = :c WHERE id = :id"), {"c": req.category, "id": app_id}) if req.tags is not None: db.execute(text("UPDATE market_apps SET tags = :t WHERE id = :id"), {"t": req.tags, "id": app_id}) if req.price is not None: app.price = req.price if req.is_member_free is not None: app.is_member_free = req.is_member_free if req.featured is not None: db.execute(text("UPDATE market_apps SET featured = :f WHERE id = :id"), {"f": int(req.featured), "id": app_id}) if req.featured_order is not None: db.execute(text("UPDATE market_apps SET featured_order = :fo WHERE id = :id"), {"fo": req.featured_order, "id": app_id}) if req.status is not None: app.status = req.status app.updated_at = datetime.now() db.commit() db.refresh(app) return {"code": 0, "message": "更新成功", "data": _app_to_dict(app)} @router.put("/apps/{app_id}/featured", summary="设置推荐应用") async def set_featured( app_id: str, req: FeaturedUpdateRequest, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """设置/取消推荐应用""" app = db.query(MarketApp).filter(MarketApp.id == app_id).first() if not app: raise HTTPException(status_code=404, detail="应用不存在") db.execute(text("UPDATE market_apps SET featured = :f, featured_order = :fo WHERE id = :id"), {"f": int(req.featured), "fo": req.featured_order, "id": app_id}) db.commit() action = "Featured" if req.featured else "Unfeatured" LogService.log_action(db, "admin", admin_user["id"], "feature_app", f"{action} app: {app.name}") return {"code": 0, "message": f"{'已推荐' if req.featured else '已取消推荐'}", "data": {"featured": req.featured, "featured_order": req.featured_order}} @router.put("/apps/{app_id}/category", summary="设置应用分类") async def set_category( app_id: str, req: CategoryUpdateRequest, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """设置应用分类和标签""" if req.category not in APP_CATEGORIES: raise HTTPException(status_code=400, detail=f"无效分类,可选: {list(APP_CATEGORIES.keys())}") app = db.query(MarketApp).filter(MarketApp.id == app_id).first() if not app: raise HTTPException(status_code=404, detail="应用不存在") db.execute(text("UPDATE market_apps SET category = :c, tags = :t WHERE id = :id"), {"c": req.category, "t": req.tags, "id": app_id}) db.commit() return {"code": 0, "message": "分类已更新", "data": {"category": req.category, "tags": req.tags}} @router.get("/categories", summary="获取应用分类列表") async def get_categories( admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取所有应用分类及每分类的应用数""" cat_stats = _execute_raw(db, """ SELECT COALESCE(category, 'other') as category, COUNT(*) as cnt FROM market_apps WHERE status = 'published' GROUP BY category """) cat_map = {r["category"]: r["cnt"] for r in cat_stats} result = [] for key, label in APP_CATEGORIES.items(): result.append({ "key": key, "label": label, "count": cat_map.get(key, 0), }) return {"code": 0, "data": result} @router.delete("/apps/{app_id}", summary="删除应用") async def delete_app( app_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """删除市场应用""" app = db.query(MarketApp).filter(MarketApp.id == app_id).first() if not app: raise HTTPException(status_code=404, detail="应用不存在") app_name = app.name db.delete(app) db.commit() LogService.log_action(db, "admin", admin_user["id"], "delete_market_app", f"Deleted app: {app_name}") return {"code": 0, "message": "已删除"} # ============================================================ # Coupon management (kept from original) # ============================================================ @router.get("/coupons", summary="获取优惠券列表") async def list_coupons( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取优惠券列表""" from ...models import Coupon query = db.query(Coupon) total = query.count() coupons = query.order_by(desc(Coupon.created_at)).offset( (page - 1) * page_size ).limit(page_size).all() return { "code": 0, "data": { "total": total, "page": page, "page_size": page_size, "items": [{ "id": c.id, "code": c.code, "type": c.type, "value": float(c.value) if c.value else 0, "min_amount": float(c.min_amount) if c.min_amount else 0, "valid_from": c.valid_from.strftime('%Y-%m-%d %H:%M:%S') if c.valid_from else None, "valid_to": c.valid_to.strftime('%Y-%m-%d %H:%M:%S') if c.valid_to else None, "total_count": c.total_count, "used_count": c.used_count, "status": c.status, } for c in coupons], } } class CouponCreateRequest(BaseModel): code: str type: str # fixed / percent value: float min_amount: float = 0 valid_from: Optional[str] = None valid_to: Optional[str] = None total_count: int = 100 @router.post("/coupons", summary="创建优惠券") async def create_coupon( req: CouponCreateRequest, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """创建优惠券""" from ...models import Coupon coupon = Coupon( id=str(uuid.uuid4())[:8], code=req.code, type=req.type, value=req.value, min_amount=req.min_amount, valid_from=datetime.strptime(req.valid_from, '%Y-%m-%d') if req.valid_from else datetime.now(), valid_to=datetime.strptime(req.valid_to, '%Y-%m-%d') if req.valid_to else None, total_count=req.total_count, used_count=0, status="active", ) db.add(coupon) db.commit() return {"code": 0, "message": "创建成功", "data": {"id": coupon.id, "code": coupon.code}} @router.put("/coupons/{coupon_id}", summary="更新优惠券") async def update_coupon( coupon_id: str, req: CouponCreateRequest, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """更新优惠券""" from ...models import Coupon coupon = db.query(Coupon).filter(Coupon.id == coupon_id).first() if not coupon: raise HTTPException(status_code=404, detail="优惠券不存在") coupon.code = req.code coupon.type = req.type coupon.value = req.value coupon.min_amount = req.min_amount coupon.total_count = req.total_count if req.valid_from: coupon.valid_from = datetime.strptime(req.valid_from, '%Y-%m-%d') if req.valid_to: coupon.valid_to = datetime.strptime(req.valid_to, '%Y-%m-%d') db.commit() return {"code": 0, "message": "更新成功"} @router.delete("/coupons/{coupon_id}", summary="删除优惠券") async def delete_coupon( coupon_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """删除优惠券""" from ...models import Coupon coupon = db.query(Coupon).filter(Coupon.id == coupon_id).first() if not coupon: raise HTTPException(status_code=404, detail="优惠券不存在") db.delete(coupon) db.commit() return {"code": 0, "message": "已删除"}