Ver código fonte

feat(store): 添加应用商店 API 服务

实现 TappoCloud 应用商店后端,提供以下功能:
- App/Category/Developer/AppVersion 数据模型
- 应用列表、详情、分类查询 API
- 异步数据库连接(aiomysql)
- Docker 容器化部署配置
dodo hold 7 meses atrás
pai
commit
aaee9aff14

+ 14 - 0
store/.dockerignore

@@ -0,0 +1,14 @@
+__pycache__
+*.pyc
+*.pyo
+.git
+.gitignore
+.env
+.env.*
+!.env.example
+*.md
+.vscode
+.idea
+*.log
+.pytest_cache
+.mypy_cache

+ 21 - 0
store/Dockerfile

@@ -0,0 +1,21 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    gcc \
+    default-libmysqlclient-dev \
+    pkg-config \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY app/ ./app/
+
+ENV PYTHONUNBUFFERED=1
+ENV PYTHONDONTWRITEBYTECODE=1
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

+ 0 - 0
store/app/__init__.py


+ 40 - 0
store/app/config.py

@@ -0,0 +1,40 @@
+from functools import lru_cache
+from pathlib import Path
+
+from dotenv import load_dotenv
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+_BASE_DIR = Path(__file__).resolve().parent.parent
+_ENV_FILE = _BASE_DIR / ".env"
+
+load_dotenv(_ENV_FILE)
+
+
+class Settings(BaseSettings):
+    model_config = SettingsConfigDict(
+        env_file=str(_ENV_FILE),
+        env_file_encoding="utf-8",
+        env_prefix="TAPPO_",
+        case_sensitive=False,
+    )
+
+    app_name: str = "TappoCloud Store API"
+    debug: bool = False
+
+    db_host: str = "localhost"
+    db_port: int = 3306
+    db_user: str = "root"
+    db_password: str = ""
+    db_name: str = "tappocloud"
+
+    default_page_size: int = 20
+    max_page_size: int = 100
+
+    @property
+    def database_url(self) -> str:
+        return f"mysql+aiomysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
+
+
+@lru_cache
+def get_settings() -> Settings:
+    return Settings()

+ 36 - 0
store/app/database.py

@@ -0,0 +1,36 @@
+from collections.abc import AsyncGenerator
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.orm import DeclarativeBase
+
+from .config import get_settings
+
+settings = get_settings()
+
+engine = create_async_engine(
+    settings.database_url,
+    echo=settings.debug,
+    pool_pre_ping=True,
+    pool_size=10,
+    max_overflow=20,
+    pool_recycle=3600,
+)
+
+async_session_factory = async_sessionmaker(
+    bind=engine,
+    class_=AsyncSession,
+    expire_on_commit=False,
+    autoflush=False,
+)
+
+
+class Base(DeclarativeBase):
+    pass
+
+
+async def get_db() -> AsyncGenerator[AsyncSession, None]:
+    async with async_session_factory() as session:
+        try:
+            yield session
+        finally:
+            await session.close()

+ 31 - 0
store/app/main.py

@@ -0,0 +1,31 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from .config import get_settings
+from .routers import apps_router, categories_router
+
+settings = get_settings()
+
+app = FastAPI(
+    title=settings.app_name,
+    version="1.0.0",
+    docs_url="/docs",
+    redoc_url="/redoc",
+    openapi_url="/openapi.json",
+)
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_credentials=True,
+    allow_methods=["GET"],
+    allow_headers=["*"],
+)
+
+app.include_router(categories_router)
+app.include_router(apps_router)
+
+
+@app.get("/health", tags=["Health"])
+async def health_check():
+    return {"status": "ok"}

+ 113 - 0
store/app/models.py

@@ -0,0 +1,113 @@
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, JSON, SmallInteger, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from .database import Base
+
+
+class Developer(Base):
+    __tablename__ = "tpc_developers"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    name: Mapped[str] = mapped_column(String(128), nullable=False)
+    email: Mapped[Optional[str]] = mapped_column(String(255))
+    website: Mapped[Optional[str]] = mapped_column(String(512))
+    avatar_url: Mapped[Optional[str]] = mapped_column(String(512))
+    verified: Mapped[bool] = mapped_column(default=False, nullable=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
+    )
+
+    apps: Mapped[list["App"]] = relationship(back_populates="developer")
+
+
+class Category(Base):
+    __tablename__ = "tpc_categories"
+
+    id: Mapped[int] = mapped_column(SmallInteger, primary_key=True, autoincrement=True)
+    slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
+    name: Mapped[str] = mapped_column(String(128), nullable=False)
+    description: Mapped[Optional[str]] = mapped_column(String(255))
+    icon: Mapped[Optional[str]] = mapped_column(String(64))
+    sort_order: Mapped[int] = mapped_column(SmallInteger, default=0, nullable=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
+
+    apps: Mapped[list["App"]] = relationship(secondary="tpc_app_category_map", back_populates="categories")
+
+
+class AppCategoryMap(Base):
+    __tablename__ = "tpc_app_category_map"
+
+    app_id: Mapped[int] = mapped_column(
+        BigInteger, ForeignKey("tpc_apps.id", ondelete="CASCADE"), primary_key=True
+    )
+    category_id: Mapped[int] = mapped_column(
+        SmallInteger, ForeignKey("tpc_categories.id", ondelete="CASCADE"), primary_key=True
+    )
+
+
+class AppVersion(Base):
+    __tablename__ = "tpc_app_versions"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    app_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("tpc_apps.id", ondelete="CASCADE"), nullable=False)
+    version: Mapped[str] = mapped_column(String(64), nullable=False)
+    storage_url: Mapped[Optional[str]] = mapped_column(String(512))
+    checksum_sha256: Mapped[Optional[str]] = mapped_column(String(64))
+    size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
+    manifest: Mapped[Optional[dict]] = mapped_column(JSON)
+    release_notes: Mapped[Optional[str]] = mapped_column(Text)
+    min_client_version: Mapped[Optional[str]] = mapped_column(String(32))
+    status: Mapped[str] = mapped_column(
+        Enum("draft", "published", "yanked", name="version_status"),
+        default="draft",
+        nullable=False,
+    )
+    published_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
+
+    app: Mapped["App"] = relationship(back_populates="versions", foreign_keys=[app_id])
+
+
+class App(Base):
+    __tablename__ = "tpc_apps"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    app_uid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
+    name: Mapped[str] = mapped_column(String(255), nullable=False)
+    type: Mapped[str] = mapped_column(
+        Enum("system", "community", "external", name="app_type"), nullable=False, index=True
+    )
+    developer_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("tpc_developers.id", ondelete="SET NULL"))
+    icon_url: Mapped[Optional[str]] = mapped_column(String(512))
+    short_description: Mapped[Optional[str]] = mapped_column(String(255))
+    description: Mapped[Optional[str]] = mapped_column(Text)
+    homepage_url: Mapped[Optional[str]] = mapped_column(String(512))
+    current_version_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("tpc_app_versions.id"))
+    status: Mapped[str] = mapped_column(
+        Enum("draft", "published", "deprecated", "suspended", name="app_status"),
+        default="draft",
+        nullable=False,
+        index=True,
+    )
+    is_featured: Mapped[bool] = mapped_column(default=False, nullable=False, index=True)
+    download_count: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
+    publish_time: Mapped[Optional[datetime]] = mapped_column(DateTime, index=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
+    )
+
+    developer: Mapped[Optional["Developer"]] = relationship(back_populates="apps")
+    categories: Mapped[list["Category"]] = relationship(secondary="tpc_app_category_map", back_populates="apps")
+    versions: Mapped[list["AppVersion"]] = relationship(
+        back_populates="app",
+        foreign_keys="AppVersion.app_id",
+        order_by="AppVersion.published_at.desc()",
+    )
+    current_version: Mapped[Optional["AppVersion"]] = relationship(
+        foreign_keys=[current_version_id], lazy="joined"
+    )

+ 4 - 0
store/app/routers/__init__.py

@@ -0,0 +1,4 @@
+from .apps import router as apps_router
+from .categories import router as categories_router
+
+__all__ = ["apps_router", "categories_router"]

+ 213 - 0
store/app/routers/apps.py

@@ -0,0 +1,213 @@
+from typing import Literal, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import joinedload
+
+from ..config import get_settings
+from ..database import get_db
+from ..models import App, AppCategoryMap, AppVersion, Category
+from ..schemas import (
+    AppDetail,
+    AppListResponse,
+    AppSummary,
+    DownloadInfo,
+    ErrorResponse,
+    ManifestOut,
+    VersionInfo,
+)
+
+router = APIRouter(prefix="/api/v1", tags=["Apps"])
+
+AppType = Literal["system", "community", "external"]
+SortType = Literal["newest", "popular"]
+
+
+def _build_base_query():
+    return (
+        select(App)
+        .options(
+            joinedload(App.developer),
+            joinedload(App.categories),
+            joinedload(App.current_version),
+        )
+        .where(App.status == "published")
+    )
+
+
+def _apply_filters(
+    stmt,
+    category: Optional[str],
+    app_type: Optional[str],
+    search: Optional[str],
+    featured: Optional[bool],
+):
+    if category:
+        stmt = (
+            stmt.join(AppCategoryMap, App.id == AppCategoryMap.app_id)
+            .join(Category, Category.id == AppCategoryMap.category_id)
+            .where(Category.slug == category)
+        )
+    if app_type:
+        stmt = stmt.where(App.type == app_type)
+    if search:
+        pattern = f"%{search}%"
+        stmt = stmt.where(
+            App.name.ilike(pattern) | App.short_description.ilike(pattern)
+        )
+    if featured is not None:
+        stmt = stmt.where(App.is_featured == featured)
+    return stmt
+
+
+def _apply_sort(stmt, sort: str):
+    if sort == "popular":
+        return stmt.order_by(App.download_count.desc(), App.publish_time.desc())
+    return stmt.order_by(App.publish_time.desc(), App.id.desc())
+
+
+def _get_published_version(app: App) -> Optional[AppVersion]:
+    """Get published version, falling back to latest published if current is not published."""
+    if app.current_version and app.current_version.status == "published":
+        return app.current_version
+    return None
+
+
+def _to_version_info(version: Optional[AppVersion]) -> Optional[VersionInfo]:
+    if not version or version.status != "published":
+        return None
+    return VersionInfo(
+        version=version.version,
+        release_notes=version.release_notes,
+        min_client_version=version.min_client_version,
+        published_at=version.published_at,
+        size_bytes=version.size_bytes,
+    )
+
+
+def _to_app_summary(app: App) -> AppSummary:
+    return AppSummary(
+        app_uid=app.app_uid,
+        name=app.name,
+        type=app.type,
+        icon_url=app.icon_url,
+        short_description=app.short_description,
+        categories=app.categories,
+        developer=app.developer,
+        current_version=_to_version_info(app.current_version),
+        download_count=app.download_count,
+        is_featured=app.is_featured,
+        publish_time=app.publish_time,
+    )
+
+
+@router.get("/apps", response_model=AppListResponse)
+async def list_apps(
+    category: Optional[str] = Query(None, description="Filter by category slug"),
+    type: Optional[AppType] = Query(None, description="Filter by app type"),
+    search: Optional[str] = Query(None, min_length=1, description="Search keyword"),
+    featured: Optional[bool] = Query(None, description="Filter featured apps only"),
+    sort: SortType = Query("newest", description="Sort order"),
+    page: int = Query(1, ge=1, description="Page number"),
+    page_size: Optional[int] = Query(None, ge=1, le=100, description="Items per page"),
+    db: AsyncSession = Depends(get_db),
+) -> AppListResponse:
+    settings = get_settings()
+    limit = min(page_size or settings.default_page_size, settings.max_page_size)
+    offset = (page - 1) * limit
+
+    base_stmt = _build_base_query().distinct()
+    filtered_stmt = _apply_filters(base_stmt, category, type, search, featured)
+
+    count_base = select(func.count(func.distinct(App.id))).where(App.status == "published")
+    count_stmt = _apply_filters(count_base, category, type, search, featured)
+    total_result = await db.execute(count_stmt)
+    total = total_result.scalar_one() or 0
+
+    query_stmt = _apply_sort(filtered_stmt, sort).offset(offset).limit(limit)
+    result = await db.execute(query_stmt)
+    apps = result.scalars().unique().all()
+
+    return AppListResponse(
+        items=[_to_app_summary(app) for app in apps],
+        total=total,
+        page=page,
+        page_size=limit,
+    )
+
+
+@router.get(
+    "/apps/{app_uid}",
+    response_model=AppDetail,
+    responses={404: {"model": ErrorResponse}},
+)
+async def get_app_detail(app_uid: str, db: AsyncSession = Depends(get_db)) -> AppDetail:
+    stmt = _build_base_query().where(App.app_uid == app_uid)
+    result = await db.execute(stmt)
+    app = result.scalars().unique().one_or_none()
+
+    if not app:
+        raise HTTPException(status_code=404, detail="App not found")
+
+    manifest = None
+    if app.current_version and app.current_version.manifest:
+        m = app.current_version.manifest
+        manifest = ManifestOut(
+            pages=m.get("pages"),
+            commands=m.get("commands"),
+            permissions=m.get("permissions"),
+        )
+
+    return AppDetail(
+        app_uid=app.app_uid,
+        name=app.name,
+        type=app.type,
+        icon_url=app.icon_url,
+        short_description=app.short_description,
+        description=app.description,
+        homepage_url=app.homepage_url,
+        categories=app.categories,
+        developer=app.developer,
+        current_version=_to_version_info(app.current_version),
+        download_count=app.download_count,
+        is_featured=app.is_featured,
+        publish_time=app.publish_time,
+        manifest=manifest,
+    )
+
+
+@router.get(
+    "/apps/{app_uid}/download",
+    response_model=DownloadInfo,
+    responses={404: {"model": ErrorResponse}},
+)
+async def get_download_info(app_uid: str, db: AsyncSession = Depends(get_db)) -> DownloadInfo:
+    stmt = _build_base_query().where(App.app_uid == app_uid)
+    result = await db.execute(stmt)
+    app = result.scalars().unique().one_or_none()
+
+    if not app:
+        raise HTTPException(status_code=404, detail="App not found")
+
+    version = app.current_version
+    if not version or version.status != "published":
+        version_stmt = (
+            select(AppVersion)
+            .where(AppVersion.app_id == app.id, AppVersion.status == "published")
+            .order_by(AppVersion.published_at.desc())
+            .limit(1)
+        )
+        version_result = await db.execute(version_stmt)
+        version = version_result.scalar_one_or_none()
+
+    if not version:
+        raise HTTPException(status_code=404, detail="No published version available")
+
+    return DownloadInfo(
+        app_uid=app.app_uid,
+        version=version.version,
+        url=version.storage_url,
+        checksum_sha256=version.checksum_sha256,
+        size_bytes=version.size_bytes,
+    )

+ 17 - 0
store/app/routers/categories.py

@@ -0,0 +1,17 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from ..database import get_db
+from ..models import Category
+from ..schemas import CategoryListResponse, CategoryOut
+
+router = APIRouter(prefix="/api/v1", tags=["Categories"])
+
+
+@router.get("/categories", response_model=CategoryListResponse)
+async def list_categories(db: AsyncSession = Depends(get_db)) -> CategoryListResponse:
+    stmt = select(Category).order_by(Category.sort_order.asc(), Category.name.asc())
+    result = await db.execute(stmt)
+    categories = result.scalars().all()
+    return CategoryListResponse(items=[CategoryOut.model_validate(c) for c in categories])

+ 86 - 0
store/app/schemas.py

@@ -0,0 +1,86 @@
+from datetime import datetime
+from typing import Any, Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class BaseSchema(BaseModel):
+    model_config = ConfigDict(from_attributes=True)
+
+
+# === Developer ===
+class DeveloperOut(BaseSchema):
+    id: int
+    name: str
+    avatar_url: Optional[str] = None
+    verified: bool
+
+
+# === Category ===
+class CategoryOut(BaseSchema):
+    slug: str
+    name: str
+    description: Optional[str] = None
+    icon: Optional[str] = None
+
+
+class CategoryListResponse(BaseModel):
+    items: list[CategoryOut]
+
+
+# === Version ===
+class VersionInfo(BaseSchema):
+    version: str
+    release_notes: Optional[str] = None
+    min_client_version: Optional[str] = None
+    published_at: Optional[datetime] = None
+    size_bytes: Optional[int] = None
+
+
+class ManifestOut(BaseModel):
+    pages: Optional[list[dict[str, Any]]] = None
+    commands: Optional[list[dict[str, Any]]] = None
+    permissions: Optional[list[dict[str, Any]]] = None
+
+
+# === App ===
+class AppSummary(BaseSchema):
+    app_uid: str
+    name: str
+    type: str
+    icon_url: Optional[str] = None
+    short_description: Optional[str] = None
+    categories: list[CategoryOut] = Field(default_factory=list)
+    developer: Optional[DeveloperOut] = None
+    current_version: Optional[VersionInfo] = None
+    download_count: int = 0
+    is_featured: bool = False
+    publish_time: Optional[datetime] = None
+
+
+class AppDetail(AppSummary):
+    description: Optional[str] = None
+    homepage_url: Optional[str] = None
+    manifest: Optional[ManifestOut] = None
+
+
+class AppListResponse(BaseModel):
+    items: list[AppSummary]
+    total: int
+    page: int
+    page_size: int
+
+
+# === Download ===
+class DownloadInfo(BaseModel):
+    app_uid: str
+    version: str
+    url: Optional[str] = None
+    checksum_sha256: Optional[str] = None
+    size_bytes: Optional[int] = None
+
+
+# === Error ===
+class ErrorResponse(BaseModel):
+    code: str
+    message: str

+ 48 - 0
store/docker-compose.yml

@@ -0,0 +1,48 @@
+services:
+  store:
+    build: .
+    container_name: tappo-store
+    restart: unless-stopped
+    ports:
+      - "8000:8000"
+    environment:
+      - TAPPO_DB_HOST=mysql
+      - TAPPO_DB_PORT=3306
+      - TAPPO_DB_USER=tappo
+      - TAPPO_DB_PASSWORD=${MYSQL_PASSWORD:-tappo123}
+      - TAPPO_DB_NAME=tappocloud
+      - TAPPO_DEBUG=false
+    depends_on:
+      mysql:
+        condition: service_healthy
+    networks:
+      - tappo-network
+
+  mysql:
+    image: mysql:8.0
+    container_name: tappo-mysql
+    restart: unless-stopped
+    environment:
+      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-root123}
+      - MYSQL_DATABASE=tappocloud
+      - MYSQL_USER=tappo
+      - MYSQL_PASSWORD=${MYSQL_PASSWORD:-tappo123}
+    volumes:
+      - mysql_data:/var/lib/mysql
+      - ./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
+    ports:
+      - "3306:3306"
+    healthcheck:
+      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+    networks:
+      - tappo-network
+
+volumes:
+  mysql_data:
+
+networks:
+  tappo-network:
+    driver: bridge

+ 415 - 0
store/openapi.yaml

@@ -0,0 +1,415 @@
+openapi: 3.0.3
+info:
+  title: TappoCloud App Store API
+  description: |
+    Tappo 应用商店公开 API,供 Tappo 客户端获取应用列表、详情和下载。
+
+    ## 应用类型
+    - `system`: 系统应用(官方内置)
+    - `community`: 社区应用(第三方开发)
+    - `external`: 远程应用(SaaS 集成)
+
+    ## 应用包结构
+    ```
+    my-app/
+    ├── app.yml           # 元数据定义
+    ├── main.py           # 后端逻辑
+    ├── assets/           # 静态资源
+    │   └── icon.png
+    └── README.md
+    ```
+  version: 1.0.0
+  contact:
+    name: Tappo Team
+    url: https://tappo.dev
+  license:
+    name: Proprietary
+
+servers:
+  - url: https://store.tappo.dev/api/v1
+    description: Production
+  - url: http://localhost:8000/api/v1
+    description: Development
+
+tags:
+  - name: Categories
+    description: 应用分类
+  - name: Apps
+    description: 应用管理
+
+paths:
+  /categories:
+    get:
+      tags: [Categories]
+      summary: 获取分类列表
+      operationId: listCategories
+      responses:
+        '200':
+          description: 成功
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  items:
+                    type: array
+                    items:
+                      $ref: '#/components/schemas/Category'
+              example:
+                items:
+                  - slug: security
+                    name: 安全工具
+                    description: 密码管理、认证、加密等安全相关应用
+                    icon: shield
+                  - slug: productivity
+                    name: 效率工具
+                    description: 提升工作效率的自动化工具
+                    icon: zap
+
+  /apps:
+    get:
+      tags: [Apps]
+      summary: 获取应用列表
+      operationId: listApps
+      parameters:
+        - name: category
+          in: query
+          description: 按分类筛选 (slug)
+          schema:
+            type: string
+          example: security
+        - name: type
+          in: query
+          description: 按应用类型筛选
+          schema:
+            type: string
+            enum: [system, community, external]
+        - name: search
+          in: query
+          description: 搜索关键词 (名称/描述)
+          schema:
+            type: string
+        - name: featured
+          in: query
+          description: 仅返回推荐应用
+          schema:
+            type: boolean
+        - name: sort
+          in: query
+          description: 排序方式
+          schema:
+            type: string
+            enum: [newest, popular]
+            default: newest
+        - name: page
+          in: query
+          description: 页码 (从 1 开始)
+          schema:
+            type: integer
+            minimum: 1
+            default: 1
+        - name: page_size
+          in: query
+          description: 每页数量
+          schema:
+            type: integer
+            minimum: 1
+            maximum: 100
+            default: 20
+      responses:
+        '200':
+          description: 成功
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/AppListResponse'
+
+  /apps/{app_uid}:
+    get:
+      tags: [Apps]
+      summary: 获取应用详情
+      operationId: getApp
+      parameters:
+        - name: app_uid
+          in: path
+          required: true
+          description: 应用唯一标识
+          schema:
+            type: string
+          example: mfa-authenticator
+      responses:
+        '200':
+          description: 成功
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/AppDetail'
+        '404':
+          description: 应用不存在
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+
+  /apps/{app_uid}/download:
+    get:
+      tags: [Apps]
+      summary: 下载应用包
+      description: |
+        返回最新版本的下载信息。客户端应使用返回的 `url` 下载 ZIP 包,
+        下载后验证 `checksum_sha256` 确保完整性。
+      operationId: downloadApp
+      parameters:
+        - name: app_uid
+          in: path
+          required: true
+          description: 应用唯一标识
+          schema:
+            type: string
+          example: mfa-authenticator
+      responses:
+        '200':
+          description: 成功
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/DownloadInfo'
+        '404':
+          description: 应用不存在或无可用版本
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+
+components:
+  schemas:
+    Category:
+      type: object
+      properties:
+        slug:
+          type: string
+          description: 分类标识符
+          example: security
+        name:
+          type: string
+          description: 分类名称
+          example: 安全工具
+        description:
+          type: string
+          description: 分类描述
+          example: 密码管理、认证、加密等安全相关应用
+        icon:
+          type: string
+          description: 图标标识
+          example: shield
+      required: [slug, name]
+
+    Developer:
+      type: object
+      properties:
+        id:
+          type: integer
+          format: int64
+        name:
+          type: string
+          example: Tappo Team
+        avatar_url:
+          type: string
+          format: uri
+          nullable: true
+        verified:
+          type: boolean
+          description: 是否官方认证
+          example: true
+      required: [id, name, verified]
+
+    AppSummary:
+      type: object
+      description: 应用列表项
+      properties:
+        app_uid:
+          type: string
+          description: 应用唯一标识
+          example: mfa-authenticator
+        name:
+          type: string
+          description: 应用名称
+          example: MFA 安全令牌
+        type:
+          type: string
+          enum: [system, community, external]
+          example: system
+        icon_url:
+          type: string
+          format: uri
+          nullable: true
+          example: https://cdn.tappo.dev/apps/mfa/icon.png
+        short_description:
+          type: string
+          example: 管理 TOTP/HOTP 双因素认证令牌
+        categories:
+          type: array
+          items:
+            type: string
+          example: [security]
+        developer:
+          $ref: '#/components/schemas/Developer'
+        current_version:
+          type: string
+          description: 当前版本号
+          example: 1.2.0
+        download_count:
+          type: integer
+          format: int64
+          example: 1234
+        is_featured:
+          type: boolean
+          example: true
+        publish_time:
+          type: string
+          format: date-time
+          example: "2025-01-15T10:30:00Z"
+      required: [app_uid, name, type]
+
+    AppDetail:
+      allOf:
+        - $ref: '#/components/schemas/AppSummary'
+        - type: object
+          properties:
+            description:
+              type: string
+              description: 详细描述 (Markdown)
+              example: |
+                ## MFA 安全令牌
+
+                管理您的 TOTP/HOTP 双因素认证令牌。
+
+                ### 功能特性
+                - 支持扫码添加
+                - 自动计算验证码
+                - 数据本地加密存储
+            homepage_url:
+              type: string
+              format: uri
+              nullable: true
+            manifest:
+              $ref: '#/components/schemas/Manifest'
+            version_info:
+              $ref: '#/components/schemas/VersionInfo'
+
+    Manifest:
+      type: object
+      description: 应用清单 (来自 app.yml)
+      properties:
+        pages:
+          type: array
+          items:
+            type: object
+            properties:
+              id:
+                type: string
+              title:
+                type: string
+              window:
+                type: object
+                properties:
+                  width:
+                    type: integer
+                  height:
+                    type: integer
+        commands:
+          type: array
+          items:
+            type: object
+            properties:
+              id:
+                type: string
+              name:
+                type: string
+              description:
+                type: string
+        permissions:
+          type: array
+          items:
+            type: object
+            properties:
+              id:
+                type: string
+              description:
+                type: string
+
+    VersionInfo:
+      type: object
+      properties:
+        version:
+          type: string
+          example: 1.2.0
+        release_notes:
+          type: string
+          nullable: true
+          example: "修复了若干 Bug,提升稳定性"
+        min_client_version:
+          type: string
+          nullable: true
+          example: "0.5.0"
+        published_at:
+          type: string
+          format: date-time
+          example: "2025-01-20T08:00:00Z"
+        size_bytes:
+          type: integer
+          format: int64
+          example: 102400
+
+    DownloadInfo:
+      type: object
+      properties:
+        app_uid:
+          type: string
+          example: mfa-authenticator
+        version:
+          type: string
+          example: 1.2.0
+        url:
+          type: string
+          format: uri
+          description: ZIP 包下载地址 (可能为预签名 URL)
+          example: https://cdn.tappo.dev/apps/mfa/1.2.0/package.zip
+        checksum_sha256:
+          type: string
+          description: SHA256 校验值
+          example: a1b2c3d4e5f6...
+        size_bytes:
+          type: integer
+          format: int64
+          example: 102400
+      required: [app_uid, version, url]
+
+    AppListResponse:
+      type: object
+      properties:
+        items:
+          type: array
+          items:
+            $ref: '#/components/schemas/AppSummary'
+        page:
+          type: integer
+          example: 1
+        page_size:
+          type: integer
+          example: 20
+        total:
+          type: integer
+          description: 总记录数
+          example: 42
+
+    Error:
+      type: object
+      properties:
+        code:
+          type: string
+          example: NOT_FOUND
+        message:
+          type: string
+          example: 应用不存在
+      required: [code, message]

+ 7 - 0
store/requirements.txt

@@ -0,0 +1,7 @@
+fastapi>=0.111.0
+uvicorn[standard]>=0.30.0
+sqlalchemy>=2.0.30
+aiomysql>=0.2.0
+pydantic>=2.7.0
+pydantic-settings>=2.2.0
+python-dotenv>=1.0.0

+ 105 - 0
store/schema.sql

@@ -0,0 +1,105 @@
+-- ============================================================
+-- TappoCloud App Store - MySQL Schema
+-- Version: 1.0.0
+-- Table Prefix: tpc_
+-- ============================================================
+
+-- 开发者表
+CREATE TABLE IF NOT EXISTS tpc_developers (
+    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+    name VARCHAR(128) NOT NULL COMMENT '开发者名称',
+    email VARCHAR(255) COMMENT '联系邮箱',
+    website VARCHAR(512) COMMENT '官网地址',
+    avatar_url VARCHAR(512) COMMENT '头像 URL',
+    verified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否官方认证',
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_name (name)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='开发者信息表';
+
+-- 分类表
+CREATE TABLE IF NOT EXISTS tpc_categories (
+    id SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+    slug VARCHAR(64) NOT NULL UNIQUE COMMENT '分类标识符 (URL 友好)',
+    name VARCHAR(128) NOT NULL COMMENT '分类显示名称',
+    description VARCHAR(255) COMMENT '分类描述',
+    icon VARCHAR(64) COMMENT '分类图标 (可选)',
+    sort_order SMALLINT NOT NULL DEFAULT 0 COMMENT '排序权重',
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应用分类表';
+
+-- 应用主表
+CREATE TABLE IF NOT EXISTS tpc_apps (
+    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+    app_uid VARCHAR(64) NOT NULL UNIQUE COMMENT '应用唯一标识 (如 mfa-authenticator)',
+    name VARCHAR(255) NOT NULL COMMENT '应用名称',
+    type ENUM('system', 'community', 'external') NOT NULL COMMENT '应用类型',
+    developer_id BIGINT UNSIGNED COMMENT '开发者 ID',
+    icon_url VARCHAR(512) COMMENT '应用图标 URL',
+    short_description VARCHAR(255) COMMENT '简短描述',
+    description TEXT COMMENT '详细描述 (支持 Markdown)',
+    homepage_url VARCHAR(512) COMMENT '应用主页',
+    current_version_id BIGINT UNSIGNED COMMENT '当前版本 ID',
+    status ENUM('draft', 'published', 'deprecated', 'suspended') NOT NULL DEFAULT 'draft' COMMENT '应用状态',
+    is_featured TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否推荐',
+    download_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '下载次数',
+    publish_time DATETIME COMMENT '首次发布时间',
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    FOREIGN KEY (developer_id) REFERENCES tpc_developers(id) ON DELETE SET NULL,
+    INDEX idx_type (type),
+    INDEX idx_status (status),
+    INDEX idx_featured (is_featured),
+    INDEX idx_publish_time (publish_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应用主表';
+
+-- 应用-分类关联表 (多对多)
+CREATE TABLE IF NOT EXISTS tpc_app_category_map (
+    app_id BIGINT UNSIGNED NOT NULL,
+    category_id SMALLINT UNSIGNED NOT NULL,
+    PRIMARY KEY (app_id, category_id),
+    FOREIGN KEY (app_id) REFERENCES tpc_apps(id) ON DELETE CASCADE,
+    FOREIGN KEY (category_id) REFERENCES tpc_categories(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应用分类关联表';
+
+-- 应用版本表
+CREATE TABLE IF NOT EXISTS tpc_app_versions (
+    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+    app_id BIGINT UNSIGNED NOT NULL,
+    version VARCHAR(64) NOT NULL COMMENT '版本号 (SemVer)',
+    storage_url VARCHAR(512) NOT NULL COMMENT 'ZIP 包存储 URL (OSS/S3)',
+    checksum_sha256 CHAR(64) COMMENT 'SHA256 校验值',
+    size_bytes BIGINT UNSIGNED COMMENT '文件大小 (字节)',
+    manifest JSON COMMENT 'app.yml 解析后的 JSON (pages, commands, permissions)',
+    release_notes TEXT COMMENT '版本更新说明',
+    min_client_version VARCHAR(32) COMMENT '最低客户端版本要求',
+    status ENUM('draft', 'published', 'yanked') NOT NULL DEFAULT 'draft' COMMENT '版本状态',
+    published_at DATETIME COMMENT '发布时间',
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_app_version (app_id, version),
+    FOREIGN KEY (app_id) REFERENCES tpc_apps(id) ON DELETE CASCADE,
+    INDEX idx_status (status),
+    INDEX idx_published_at (published_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应用版本表';
+
+-- 添加 tpc_apps.current_version_id 外键 (延迟添加避免循环依赖)
+ALTER TABLE tpc_apps
+ADD CONSTRAINT fk_current_version
+FOREIGN KEY (current_version_id) REFERENCES tpc_app_versions(id) ON DELETE SET NULL;
+
+-- ============================================================
+-- 初始数据
+-- ============================================================
+
+-- 预置分类
+INSERT INTO tpc_categories (slug, name, description, sort_order) VALUES
+('security', '安全工具', '密码管理、认证、加密等安全相关应用', 10),
+('productivity', '效率工具', '提升工作效率的自动化工具', 20),
+('tool', '实用工具', '通用工具类应用', 30),
+('ai', 'AI 应用', '人工智能、机器学习相关应用', 40),
+('data', '数据处理', '数据转换、分析、导入导出工具', 50),
+('integration', '集成服务', '第三方服务集成 (SaaS)', 60);
+
+-- 预置官方开发者
+INSERT INTO tpc_developers (name, email, website, verified) VALUES
+('Tappo Team', 'dev@tappo.dev', 'https://tappo.dev', 1);