From b5a62ee9a3a1784bb6da35df0537d49f674dbab7 Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Mon, 15 Jun 2026 15:17:40 +0800 Subject: [PATCH] feat: initial SaaS scaffold - domain: User, Workspace, Project, AssetLibrary, Asset, IngestJob - ports: repository interfaces - application: use cases - adapters: in-memory - API: FastAPI 5 routes - worker: Celery ingest_asset - tests: 4 passing --- .env.example | 11 + .gitignore | 36 ++ README.md | 27 + apps/api/README.md | 15 + apps/api/__init__.py | 3 + apps/api/app/__init__.py | 1 + apps/api/app/api/__init__.py | 1 + apps/api/app/api/router.py | 14 + apps/api/app/api/routes/__init__.py | 1 + apps/api/app/api/routes/asset_libraries.py | 55 ++ apps/api/app/api/routes/assets.py | 61 ++ apps/api/app/api/routes/health.py | 10 + apps/api/app/api/routes/ingest_jobs.py | 35 ++ apps/api/app/api/routes/projects.py | 49 ++ apps/api/app/core/__init__.py | 1 + apps/api/app/core/config.py | 11 + apps/api/app/dependencies.py | 28 + apps/api/app/schemas/__init__.py | 22 + apps/api/app/schemas/asset.py | 26 + apps/api/app/schemas/asset_library.py | 20 + apps/api/app/schemas/health.py | 6 + apps/api/app/schemas/ingest_job.py | 19 + apps/api/app/schemas/project.py | 18 + apps/api/main.py | 14 + apps/api/routers.py | 8 + apps/web/.keep | 3 + apps/web/README.md | 14 + apps/web/app/layout.tsx | 12 + apps/web/app/page.tsx | 14 + apps/web/next-env.d.ts | 4 + apps/web/next.config.js | 6 + apps/web/package.json | 24 + apps/web/tsconfig.json | 20 + apps/worker/README.md | 12 + apps/worker/celery_app.py | 11 + apps/worker/main.py | 10 + apps/worker/tasks.py | 6 + apps/worker/worker_app/__init__.py | 1 + apps/worker/worker_app/celery_app.py | 9 + apps/worker/worker_app/core/__init__.py | 1 + apps/worker/worker_app/core/config.py | 11 + apps/worker/worker_app/tasks/__init__.py | 1 + apps/worker/worker_app/tasks/health.py | 23 + docs/STARTUP.md | 25 + docs/TREE.md | 25 + docs/ai-team-tooling-checklist.md | 281 +++++++++ docs/infrastructure-keep-rebuild-matrix.md | 263 ++++++++ docs/saas-core-flows.md | 268 ++++++++ docs/saas-core-objects.md | 353 +++++++++++ docs/saas-development-workflow.md | 228 +++++++ docs/saas-index.md | 66 ++ docs/saas-mvp-scope.md | 194 ++++++ docs/saas-project-bootstrap-location.md | 123 ++++ docs/saas-project-structure-spec.md | 247 ++++++++ docs/saas-rebuild-inventory.md | 354 +++++++++++ docs/saas-rebuild-kickoff-plan.md | 577 ++++++++++++++++++ docs/saas-tech-stack-options.md | 274 +++++++++ infra/docker/api.Dockerfile | 9 + infra/docker/compose.yml | 55 ++ infra/docker/worker.Dockerfile | 9 + infra/nginx/default.conf | 12 + infra/scripts/bootstrap-notes.md | 16 + package.json | 14 + packages/__init__.py | 1 + packages/adapters/__init__.py | 1 + packages/adapters/in_memory/__init__.py | 11 + .../in_memory/asset_library_repository.py | 19 + .../adapters/in_memory/asset_repository.py | 16 + .../in_memory/ingest_job_repository.py | 19 + .../adapters/in_memory/project_repository.py | 16 + packages/application/__init__.py | 20 + packages/application/asset_libraries.py | 38 ++ packages/application/assets.py | 44 ++ packages/application/ingest_jobs.py | 28 + packages/application/projects.py | 36 ++ packages/domain/__init__.py | 14 + packages/domain/entities.py | 165 +++++ packages/ports/__init__.py | 13 + packages/ports/asset_library_repository.py | 13 + packages/ports/asset_repository.py | 13 + packages/ports/ingest_job_repository.py | 16 + packages/ports/project_repository.py | 13 + packages/shared/__init__.py | 1 + pytest.ini | 3 + requirements.txt | 10 + tests/conftest.py | 8 + tests/e2e/README.md | 1 + tests/integration/README.md | 1 + tests/integration/test_projects.py | 102 ++++ 89 files changed, 4689 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/api/README.md create mode 100644 apps/api/__init__.py create mode 100644 apps/api/app/__init__.py create mode 100644 apps/api/app/api/__init__.py create mode 100644 apps/api/app/api/router.py create mode 100644 apps/api/app/api/routes/__init__.py create mode 100644 apps/api/app/api/routes/asset_libraries.py create mode 100644 apps/api/app/api/routes/assets.py create mode 100644 apps/api/app/api/routes/health.py create mode 100644 apps/api/app/api/routes/ingest_jobs.py create mode 100644 apps/api/app/api/routes/projects.py create mode 100644 apps/api/app/core/__init__.py create mode 100644 apps/api/app/core/config.py create mode 100644 apps/api/app/dependencies.py create mode 100644 apps/api/app/schemas/__init__.py create mode 100644 apps/api/app/schemas/asset.py create mode 100644 apps/api/app/schemas/asset_library.py create mode 100644 apps/api/app/schemas/health.py create mode 100644 apps/api/app/schemas/ingest_job.py create mode 100644 apps/api/app/schemas/project.py create mode 100644 apps/api/main.py create mode 100644 apps/api/routers.py create mode 100644 apps/web/.keep create mode 100644 apps/web/README.md create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.js create mode 100644 apps/web/package.json create mode 100644 apps/web/tsconfig.json create mode 100644 apps/worker/README.md create mode 100644 apps/worker/celery_app.py create mode 100644 apps/worker/main.py create mode 100644 apps/worker/tasks.py create mode 100644 apps/worker/worker_app/__init__.py create mode 100644 apps/worker/worker_app/celery_app.py create mode 100644 apps/worker/worker_app/core/__init__.py create mode 100644 apps/worker/worker_app/core/config.py create mode 100644 apps/worker/worker_app/tasks/__init__.py create mode 100644 apps/worker/worker_app/tasks/health.py create mode 100644 docs/STARTUP.md create mode 100644 docs/TREE.md create mode 100644 docs/ai-team-tooling-checklist.md create mode 100644 docs/infrastructure-keep-rebuild-matrix.md create mode 100644 docs/saas-core-flows.md create mode 100644 docs/saas-core-objects.md create mode 100644 docs/saas-development-workflow.md create mode 100644 docs/saas-index.md create mode 100644 docs/saas-mvp-scope.md create mode 100644 docs/saas-project-bootstrap-location.md create mode 100644 docs/saas-project-structure-spec.md create mode 100644 docs/saas-rebuild-inventory.md create mode 100644 docs/saas-rebuild-kickoff-plan.md create mode 100644 docs/saas-tech-stack-options.md create mode 100644 infra/docker/api.Dockerfile create mode 100644 infra/docker/compose.yml create mode 100644 infra/docker/worker.Dockerfile create mode 100644 infra/nginx/default.conf create mode 100644 infra/scripts/bootstrap-notes.md create mode 100644 package.json create mode 100644 packages/__init__.py create mode 100644 packages/adapters/__init__.py create mode 100644 packages/adapters/in_memory/__init__.py create mode 100644 packages/adapters/in_memory/asset_library_repository.py create mode 100644 packages/adapters/in_memory/asset_repository.py create mode 100644 packages/adapters/in_memory/ingest_job_repository.py create mode 100644 packages/adapters/in_memory/project_repository.py create mode 100644 packages/application/__init__.py create mode 100644 packages/application/asset_libraries.py create mode 100644 packages/application/assets.py create mode 100644 packages/application/ingest_jobs.py create mode 100644 packages/application/projects.py create mode 100644 packages/domain/__init__.py create mode 100644 packages/domain/entities.py create mode 100644 packages/ports/__init__.py create mode 100644 packages/ports/asset_library_repository.py create mode 100644 packages/ports/asset_repository.py create mode 100644 packages/ports/ingest_job_repository.py create mode 100644 packages/ports/project_repository.py create mode 100644 packages/shared/__init__.py create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/e2e/README.md create mode 100644 tests/integration/README.md create mode 100644 tests/integration/test_projects.py diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..7a76657e6 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Environment example +APP_ENV=development +APP_NAME=xiaoxia-saas +API_HOST=0.0.0.0 +API_PORT=8000 +WEB_PORT=3000 +POSTGRES_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas +REDIS_URL=redis://localhost:6379/0 +OBJECT_STORAGE_PROVIDER=minio +OBJECT_STORAGE_ENDPOINT=http://localhost:9000 +OBJECT_STORAGE_BUCKET=xiaoxia-saas diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..1e98cdac4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Node / frontend +node_modules/ +.next/ +out/ +dist/ +coverage/ + +# Python / backend +.venv/ +venv/ +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.pytype/ +ruff_cache/ +*.pyc + +# Env / secrets +.env +.env.* +!.env.example + +# OS / editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# Logs / temp +*.log +tmp/ +temp/ + +# Build / runtime artifacts +build/ +.runtime/ diff --git a/README.md b/README.md new file mode 100644 index 000000000..0f81abcb6 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# xiaoxia-saas + +小虾自动剪辑 SaaS 新主线仓库。 + +## 目标 + +- 与旧桌面版彻底隔离 +- 按正规 SaaS 工程方式重建 +- 先按文档、架构、制度启动,再进入开发 + +## 目录 + +- `docs/`:产品、架构、流程、盘点和启动文档 +- `apps/web/`:前端 Web 应用 +- `apps/api/`:后端 API 应用 +- `apps/worker/`:后台任务执行应用 +- `packages/domain/`:核心业务对象与规则 +- `packages/application/`:用例层 +- `packages/ports/`:接口定义 +- `packages/adapters/`:外部实现 +- `packages/shared/`:共享基础模块 +- `infra/`:部署与运行基础设施 +- `tests/`:集成测试与端到端测试 + +## 当前状态 + +当前仓库已完成 SaaS 启动期文档骨架和目录骨架,下一步进入工程初始化。 diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 000000000..369057f4c --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,15 @@ +# xiaoxia-saas API + +## 结构 + +- `app/core/`:配置与基础能力 +- `app/api/`:路由组织 +- `app/schemas/`:请求响应模型 +- `app/dependencies.py`:依赖注入入口 +- `main.py`:FastAPI 启动入口 + +## 当前可用接口 + +- `GET /api/health` +- `GET /api/projects?workspace_id=...` +- `POST /api/projects` diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 000000000..2f5621977 --- /dev/null +++ b/apps/api/__init__.py @@ -0,0 +1,3 @@ +from .main import app, create_app + +__all__ = ["app", "create_app"] diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 000000000..082eb4cf5 --- /dev/null +++ b/apps/api/app/__init__.py @@ -0,0 +1 @@ +"""API application package.""" diff --git a/apps/api/app/api/__init__.py b/apps/api/app/api/__init__.py new file mode 100644 index 000000000..dff53e5af --- /dev/null +++ b/apps/api/app/api/__init__.py @@ -0,0 +1 @@ +"""API package.""" diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py new file mode 100644 index 000000000..e8e6343ba --- /dev/null +++ b/apps/api/app/api/router.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter + +from app.api.routes.asset_libraries import router as asset_libraries_router +from app.api.routes.assets import router as assets_router +from app.api.routes.health import router as health_router +from app.api.routes.ingest_jobs import router as ingest_jobs_router +from app.api.routes.projects import router as projects_router + +api_router = APIRouter() +api_router.include_router(health_router, prefix="/health", tags=["health"]) +api_router.include_router(projects_router, prefix="/projects", tags=["projects"]) +api_router.include_router(asset_libraries_router, prefix="/asset-libraries", tags=["asset-libraries"]) +api_router.include_router(assets_router, prefix="/assets", tags=["assets"]) +api_router.include_router(ingest_jobs_router, prefix="/ingest-jobs", tags=["ingest-jobs"]) diff --git a/apps/api/app/api/routes/__init__.py b/apps/api/app/api/routes/__init__.py new file mode 100644 index 000000000..f36ec2fa9 --- /dev/null +++ b/apps/api/app/api/routes/__init__.py @@ -0,0 +1 @@ +"""Route modules.""" diff --git a/apps/api/app/api/routes/asset_libraries.py b/apps/api/app/api/routes/asset_libraries.py new file mode 100644 index 000000000..f26e6394a --- /dev/null +++ b/apps/api/app/api/routes/asset_libraries.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends + +from app.dependencies import get_asset_library_repository +from app.schemas.asset_library import AssetLibraryResponse, CreateAssetLibraryRequest, ListAssetLibrariesResponse +from packages.adapters.in_memory import InMemoryAssetLibraryRepository +from packages.application import CreateAssetLibraryCommand, CreateAssetLibraryUseCase, ListAssetLibrariesUseCase +from packages.domain import AssetLibraryKind + +router = APIRouter() + + +@router.get("", response_model=ListAssetLibrariesResponse) +def list_asset_libraries( + project_id: str, + kind: str | None = None, + asset_library_repository: InMemoryAssetLibraryRepository = Depends(get_asset_library_repository), +) -> ListAssetLibrariesResponse: + use_case = ListAssetLibrariesUseCase(asset_library_repository) + parsed_kind = AssetLibraryKind(kind) if kind else None + items = use_case.execute(project_id, kind=parsed_kind) + return ListAssetLibrariesResponse( + items=[ + AssetLibraryResponse( + id=item.id, + workspace_id=item.workspace_id, + project_id=item.project_id, + name=item.name, + kind=item.kind.value, + ) + for item in items + ] + ) + + +@router.post("", response_model=AssetLibraryResponse) +def create_asset_library( + request: CreateAssetLibraryRequest, + asset_library_repository: InMemoryAssetLibraryRepository = Depends(get_asset_library_repository), +) -> AssetLibraryResponse: + use_case = CreateAssetLibraryUseCase(asset_library_repository) + item = use_case.execute( + CreateAssetLibraryCommand( + workspace_id=request.workspace_id, + project_id=request.project_id, + name=request.name, + kind=AssetLibraryKind(request.kind), + ) + ) + return AssetLibraryResponse( + id=item.id, + workspace_id=item.workspace_id, + project_id=item.project_id, + name=item.name, + kind=item.kind.value, + ) diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py new file mode 100644 index 000000000..e222729aa --- /dev/null +++ b/apps/api/app/api/routes/assets.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, Depends + +from app.dependencies import get_asset_repository +from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse +from packages.adapters.in_memory import InMemoryAssetRepository +from packages.application import CreateAssetCommand, CreateAssetUseCase, ListAssetsUseCase + +router = APIRouter() + + +@router.get("", response_model=ListAssetsResponse) +def list_assets( + library_id: str, + asset_repository: InMemoryAssetRepository = Depends(get_asset_repository), +) -> ListAssetsResponse: + use_case = ListAssetsUseCase(asset_repository) + items = use_case.execute(library_id) + return ListAssetsResponse( + items=[ + AssetResponse( + id=item.id, + workspace_id=item.workspace_id, + project_id=item.project_id, + library_id=item.library_id, + name=item.name, + storage_key=item.storage_key, + mime_type=item.mime_type, + metadata=item.metadata, + ) + for item in items + ] + ) + + +@router.post("", response_model=AssetResponse) +def create_asset( + request: CreateAssetRequest, + asset_repository: InMemoryAssetRepository = Depends(get_asset_repository), +) -> AssetResponse: + use_case = CreateAssetUseCase(asset_repository) + item = use_case.execute( + CreateAssetCommand( + workspace_id=request.workspace_id, + project_id=request.project_id, + library_id=request.library_id, + name=request.name, + storage_key=request.storage_key, + mime_type=request.mime_type, + metadata=request.metadata, + ) + ) + return AssetResponse( + id=item.id, + workspace_id=item.workspace_id, + project_id=item.project_id, + library_id=item.library_id, + name=item.name, + storage_key=item.storage_key, + mime_type=item.mime_type, + metadata=item.metadata, + ) diff --git a/apps/api/app/api/routes/health.py b/apps/api/app/api/routes/health.py new file mode 100644 index 000000000..08bd47aea --- /dev/null +++ b/apps/api/app/api/routes/health.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from app.schemas.health import HealthResponse + +router = APIRouter() + + +@router.get("", response_model=HealthResponse) +def get_health() -> HealthResponse: + return HealthResponse(ok=True, service="api") diff --git a/apps/api/app/api/routes/ingest_jobs.py b/apps/api/app/api/routes/ingest_jobs.py new file mode 100644 index 000000000..c6a23bd90 --- /dev/null +++ b/apps/api/app/api/routes/ingest_jobs.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, Depends + +from app.dependencies import get_ingest_job_repository +from app.schemas.ingest_job import IngestJobResponse, SubmitIngestJobRequest +from packages.adapters.in_memory import InMemoryIngestJobRepository +from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase + +router = APIRouter() + + +@router.post("", response_model=IngestJobResponse) +def submit_ingest_job( + request: SubmitIngestJobRequest, + ingest_job_repository: InMemoryIngestJobRepository = Depends(get_ingest_job_repository), +) -> IngestJobResponse: + use_case = SubmitIngestJobUseCase(ingest_job_repository) + job = use_case.execute( + SubmitIngestJobCommand( + workspace_id=request.workspace_id, + project_id=request.project_id, + library_id=request.library_id, + storage_key=request.storage_key, + ) + ) + # TODO: enqueue async worker task here + return IngestJobResponse( + id=job.id, + workspace_id=job.workspace_id, + project_id=job.project_id, + library_id=job.library_id, + storage_key=job.storage_key, + status=job.status.value, + error_message=job.error_message, + result_asset_id=job.result_asset_id, + ) diff --git a/apps/api/app/api/routes/projects.py b/apps/api/app/api/routes/projects.py new file mode 100644 index 000000000..8af550475 --- /dev/null +++ b/apps/api/app/api/routes/projects.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends + +from app.dependencies import get_project_repository +from app.schemas.project import CreateProjectRequest, ListProjectsResponse, ProjectResponse +from packages.application import CreateProjectCommand, CreateProjectUseCase, ListProjectsUseCase +from packages.adapters.in_memory import InMemoryProjectRepository + +router = APIRouter() + + +@router.get("", response_model=ListProjectsResponse) +def list_projects( + workspace_id: str, + project_repository: InMemoryProjectRepository = Depends(get_project_repository), +) -> ListProjectsResponse: + use_case = ListProjectsUseCase(project_repository) + projects = use_case.execute(workspace_id) + return ListProjectsResponse( + items=[ + ProjectResponse( + id=item.id, + workspace_id=item.workspace_id, + name=item.name, + description=item.description, + ) + for item in projects + ] + ) + + +@router.post("", response_model=ProjectResponse) +def create_project( + request: CreateProjectRequest, + project_repository: InMemoryProjectRepository = Depends(get_project_repository), +) -> ProjectResponse: + use_case = CreateProjectUseCase(project_repository) + project = use_case.execute( + CreateProjectCommand( + workspace_id=request.workspace_id, + name=request.name, + description=request.description, + ) + ) + return ProjectResponse( + id=project.id, + workspace_id=project.workspace_id, + name=project.name, + description=project.description, + ) diff --git a/apps/api/app/core/__init__.py b/apps/api/app/core/__init__.py new file mode 100644 index 000000000..732b41164 --- /dev/null +++ b/apps/api/app/core/__init__.py @@ -0,0 +1 @@ +"""Core configuration package.""" diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py new file mode 100644 index 000000000..bf2120b69 --- /dev/null +++ b/apps/api/app/core/config.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class AppSettings(BaseModel): + app_name: str = "xiaoxia-saas-api" + app_env: str = "development" + api_prefix: str = "/api" + + +def get_settings() -> AppSettings: + return AppSettings() diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py new file mode 100644 index 000000000..63f583736 --- /dev/null +++ b/apps/api/app/dependencies.py @@ -0,0 +1,28 @@ +from functools import lru_cache + +from packages.adapters.in_memory import ( + InMemoryAssetLibraryRepository, + InMemoryAssetRepository, + InMemoryIngestJobRepository, + InMemoryProjectRepository, +) + + +@lru_cache(maxsize=1) +def get_project_repository() -> InMemoryProjectRepository: + return InMemoryProjectRepository() + + +@lru_cache(maxsize=1) +def get_asset_library_repository() -> InMemoryAssetLibraryRepository: + return InMemoryAssetLibraryRepository() + + +@lru_cache(maxsize=1) +def get_asset_repository() -> InMemoryAssetRepository: + return InMemoryAssetRepository() + + +@lru_cache(maxsize=1) +def get_ingest_job_repository() -> InMemoryIngestJobRepository: + return InMemoryIngestJobRepository() diff --git a/apps/api/app/schemas/__init__.py b/apps/api/app/schemas/__init__.py new file mode 100644 index 000000000..226240cee --- /dev/null +++ b/apps/api/app/schemas/__init__.py @@ -0,0 +1,22 @@ +"""Schema package.""" + +from .asset import AssetResponse, CreateAssetRequest, ListAssetsResponse +from .asset_library import AssetLibraryResponse, CreateAssetLibraryRequest, ListAssetLibrariesResponse +from .health import HealthResponse +from .ingest_job import IngestJobResponse, SubmitIngestJobRequest +from .project import CreateProjectRequest, ListProjectsResponse, ProjectResponse + +__all__ = [ + "AssetResponse", + "AssetLibraryResponse", + "CreateAssetLibraryRequest", + "CreateAssetRequest", + "CreateProjectRequest", + "HealthResponse", + "IngestJobResponse", + "ListAssetLibrariesResponse", + "ListAssetsResponse", + "ListProjectsResponse", + "ProjectResponse", + "SubmitIngestJobRequest", +] diff --git a/apps/api/app/schemas/asset.py b/apps/api/app/schemas/asset.py new file mode 100644 index 000000000..bce815f67 --- /dev/null +++ b/apps/api/app/schemas/asset.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, Field + + +class CreateAssetRequest(BaseModel): + workspace_id: str = Field(..., min_length=1) + project_id: str = Field(..., min_length=1) + library_id: str = Field(..., min_length=1) + name: str = Field(..., min_length=1, max_length=100) + storage_key: str = Field(..., min_length=1, max_length=255) + mime_type: str = Field(..., min_length=1, max_length=100) + metadata: dict[str, object] = Field(default_factory=dict) + + +class AssetResponse(BaseModel): + id: str + workspace_id: str + project_id: str + library_id: str + name: str + storage_key: str + mime_type: str + metadata: dict[str, object] + + +class ListAssetsResponse(BaseModel): + items: list[AssetResponse] diff --git a/apps/api/app/schemas/asset_library.py b/apps/api/app/schemas/asset_library.py new file mode 100644 index 000000000..96a609334 --- /dev/null +++ b/apps/api/app/schemas/asset_library.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, Field + + +class CreateAssetLibraryRequest(BaseModel): + workspace_id: str = Field(..., min_length=1) + project_id: str = Field(..., min_length=1) + name: str = Field(..., min_length=1, max_length=100) + kind: str = Field(..., pattern="^(video|voice)$") + + +class AssetLibraryResponse(BaseModel): + id: str + workspace_id: str + project_id: str + name: str + kind: str + + +class ListAssetLibrariesResponse(BaseModel): + items: list[AssetLibraryResponse] diff --git a/apps/api/app/schemas/health.py b/apps/api/app/schemas/health.py new file mode 100644 index 000000000..483cbc2cd --- /dev/null +++ b/apps/api/app/schemas/health.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + ok: bool + service: str diff --git a/apps/api/app/schemas/ingest_job.py b/apps/api/app/schemas/ingest_job.py new file mode 100644 index 000000000..ca1ec8387 --- /dev/null +++ b/apps/api/app/schemas/ingest_job.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, Field + + +class SubmitIngestJobRequest(BaseModel): + workspace_id: str = Field(..., min_length=1) + project_id: str = Field(..., min_length=1) + library_id: str = Field(..., min_length=1) + storage_key: str = Field(..., min_length=1, max_length=255) + + +class IngestJobResponse(BaseModel): + id: str + workspace_id: str + project_id: str + library_id: str + storage_key: str + status: str + error_message: str + result_asset_id: str diff --git a/apps/api/app/schemas/project.py b/apps/api/app/schemas/project.py new file mode 100644 index 000000000..f3cdb4974 --- /dev/null +++ b/apps/api/app/schemas/project.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, Field + + +class CreateProjectRequest(BaseModel): + workspace_id: str = Field(..., min_length=1) + name: str = Field(..., min_length=1, max_length=100) + description: str = Field(default="", max_length=500) + + +class ProjectResponse(BaseModel): + id: str + workspace_id: str + name: str + description: str + + +class ListProjectsResponse(BaseModel): + items: list[ProjectResponse] diff --git a/apps/api/main.py b/apps/api/main.py new file mode 100644 index 000000000..a28a0a4b8 --- /dev/null +++ b/apps/api/main.py @@ -0,0 +1,14 @@ +from fastapi import FastAPI + +from app.api.router import api_router +from app.core.config import get_settings + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI(title=settings.app_name) + app.include_router(api_router, prefix=settings.api_prefix) + return app + + +app = create_app() diff --git a/apps/api/routers.py b/apps/api/routers.py new file mode 100644 index 000000000..07342c457 --- /dev/null +++ b/apps/api/routers.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +def health(): + return {"ok": True, "service": "api"} diff --git a/apps/web/.keep b/apps/web/.keep new file mode 100644 index 000000000..58381bedd --- /dev/null +++ b/apps/web/.keep @@ -0,0 +1,3 @@ +# Workspace settings + +This file keeps editor/workspace-specific settings if needed later. diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 000000000..815329b1d --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,14 @@ +# xiaoxia-saas Web + +Next.js frontend app placeholder. + +## Planned stack + +- Next.js +- TypeScript +- Tailwind CSS +- shadcn/ui + +## Current status + +Scaffold phase only. Runtime initialization will be added after final stack bootstrap. diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 000000000..928393710 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,12 @@ +export const metadata = { + title: 'xiaoxia-saas', + description: 'Xiaoxia SaaS scaffold', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 000000000..5015b7760 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,14 @@ +export default function HomePage() { + return ( +
+

xiaoxia-saas

+

SaaS 新主线前端骨架已创建。

+ +
+ ); +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 000000000..eae8aeb53 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,4 @@ +/// +/// + +// This file is auto-used by Next.js TypeScript projects. diff --git a/apps/web/next.config.js b/apps/web/next.config.js new file mode 100644 index 000000000..91ef62f0d --- /dev/null +++ b/apps/web/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +}; + +module.exports = nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 000000000..4c5dbf067 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "@xiaoxia/web", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev -p 3000", + "build": "next build", + "start": "next start -p 3000", + "lint": "next lint" + }, + "dependencies": { + "next": "14.2.5", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "typescript": "5.5.4", + "@types/node": "22.7.4", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "eslint": "8.57.1", + "eslint-config-next": "14.2.5" + } +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 000000000..d65eb8588 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["dom", "dom.iterable", "es2020"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/apps/worker/README.md b/apps/worker/README.md new file mode 100644 index 000000000..ffaf0421f --- /dev/null +++ b/apps/worker/README.md @@ -0,0 +1,12 @@ +# xiaoxia-saas Worker + +## 结构 + +- `worker_app/core/`:worker 配置 +- `worker_app/celery_app.py`:Celery 应用入口 +- `worker_app/tasks/`:任务模块 +- `main.py`:本地调试入口 + +## 当前任务 + +- `worker.healthcheck` diff --git a/apps/worker/celery_app.py b/apps/worker/celery_app.py new file mode 100644 index 000000000..d7e30e8f9 --- /dev/null +++ b/apps/worker/celery_app.py @@ -0,0 +1,11 @@ +from celery import Celery + + +def create_celery_app() -> Celery: + app = Celery("xiaoxia_saas_worker") + app.conf.broker_url = "redis://redis:6379/0" + app.conf.result_backend = "redis://redis:6379/1" + return app + + +celery_app = create_celery_app() diff --git a/apps/worker/main.py b/apps/worker/main.py new file mode 100644 index 000000000..fd883a107 --- /dev/null +++ b/apps/worker/main.py @@ -0,0 +1,10 @@ +from worker_app.tasks.health import healthcheck + + +def main() -> None: + print("xiaoxia-saas worker scaffold") + print(healthcheck.name) + + +if __name__ == "__main__": + main() diff --git a/apps/worker/tasks.py b/apps/worker/tasks.py new file mode 100644 index 000000000..d64af89b0 --- /dev/null +++ b/apps/worker/tasks.py @@ -0,0 +1,6 @@ +from .celery_app import celery_app + + +@celery_app.task(name="worker.healthcheck") +def healthcheck() -> dict: + return {"ok": True, "service": "worker"} diff --git a/apps/worker/worker_app/__init__.py b/apps/worker/worker_app/__init__.py new file mode 100644 index 000000000..b85d90fd9 --- /dev/null +++ b/apps/worker/worker_app/__init__.py @@ -0,0 +1 @@ +"""Worker application package.""" diff --git a/apps/worker/worker_app/celery_app.py b/apps/worker/worker_app/celery_app.py new file mode 100644 index 000000000..f5a7e88d0 --- /dev/null +++ b/apps/worker/worker_app/celery_app.py @@ -0,0 +1,9 @@ +from celery import Celery + +from worker_app.core.config import get_settings + + +settings = get_settings() +celery_app = Celery(settings.worker_name) +celery_app.conf.broker_url = settings.broker_url +celery_app.conf.result_backend = settings.result_backend diff --git a/apps/worker/worker_app/core/__init__.py b/apps/worker/worker_app/core/__init__.py new file mode 100644 index 000000000..02e645bc0 --- /dev/null +++ b/apps/worker/worker_app/core/__init__.py @@ -0,0 +1 @@ +"""Worker core package.""" diff --git a/apps/worker/worker_app/core/config.py b/apps/worker/worker_app/core/config.py new file mode 100644 index 000000000..789703fff --- /dev/null +++ b/apps/worker/worker_app/core/config.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class WorkerSettings(BaseModel): + worker_name: str = "xiaoxia-saas-worker" + broker_url: str = "redis://redis:6379/0" + result_backend: str = "redis://redis:6379/1" + + +def get_settings() -> WorkerSettings: + return WorkerSettings() diff --git a/apps/worker/worker_app/tasks/__init__.py b/apps/worker/worker_app/tasks/__init__.py new file mode 100644 index 000000000..e765b7666 --- /dev/null +++ b/apps/worker/worker_app/tasks/__init__.py @@ -0,0 +1 @@ +"""Task modules.""" diff --git a/apps/worker/worker_app/tasks/health.py b/apps/worker/worker_app/tasks/health.py new file mode 100644 index 000000000..741fb8079 --- /dev/null +++ b/apps/worker/worker_app/tasks/health.py @@ -0,0 +1,23 @@ +from worker_app.celery_app import celery_app + + +@celery_app.task(name="worker.healthcheck") +def healthcheck() -> dict: + return {"ok": True, "service": "worker"} + + +@celery_app.task(name="worker.ingest_asset") +def ingest_asset(job_id: str, workspace_id: str, project_id: str, library_id: str, storage_key: str) -> dict: + """ + Ingest asset task placeholder. + Real implementation would: + 1. Fetch file from storage_key + 2. Extract metadata (duration, resolution, etc.) + 3. Create Asset entity + 4. Update IngestJob status + """ + return { + "job_id": job_id, + "status": "completed", + "asset_id": "placeholder-asset-id", + } diff --git a/docs/STARTUP.md b/docs/STARTUP.md new file mode 100644 index 000000000..da924befd --- /dev/null +++ b/docs/STARTUP.md @@ -0,0 +1,25 @@ +# 新仓库启动说明 + +本仓库是 `xiaoxia-saas` 的新主线。 + +## 启动约束 + +- 新仓库与旧桌面版仓库物理隔离 +- 旧仓库只作为业务参考和历史样本 +- 新仓库所有开发都遵循 SaaS 启动文档 +- 先文档,后架构,后骨架,最后开发 + +## 入口文档 + +- `docs/saas-index.md` +- `docs/saas-rebuild-kickoff-plan.md` +- `docs/saas-rebuild-inventory.md` +- `docs/saas-project-bootstrap-location.md` +- `docs/saas-project-structure-spec.md` +- `docs/saas-development-workflow.md` + +## 当前下一步 + +1. 选择是否立即初始化前后端基础工程 +2. 确认数据库/队列/存储的本地开发方式 +3. 建立第一版 CI/CD diff --git a/docs/TREE.md b/docs/TREE.md new file mode 100644 index 000000000..326598676 --- /dev/null +++ b/docs/TREE.md @@ -0,0 +1,25 @@ +# SaaS 目录索引 + +```text +F:\openclaw-saas +├─ docs +├─ apps +│ ├─ web +│ ├─ api +│ └─ worker +├─ packages +│ ├─ domain +│ ├─ application +│ ├─ ports +│ ├─ adapters +│ └─ shared +├─ infra +│ ├─ docker +│ ├─ nginx +│ └─ scripts +├─ tests +│ ├─ integration +│ └─ e2e +├─ README.md +└─ .gitignore +``` diff --git a/docs/ai-team-tooling-checklist.md b/docs/ai-team-tooling-checklist.md new file mode 100644 index 000000000..3307825b9 --- /dev/null +++ b/docs/ai-team-tooling-checklist.md @@ -0,0 +1,281 @@ +# AI 团队工具清单(第一版) + +本文档定义新 SaaS 项目启动阶段需要的 AI 工具、用途、角色归属、优先级、获取方式和安全要求。 + +--- + +## 1. 总原则 + +我们不自研 AI 平台,前期采用: + +- 现成模型服务 +- 现成 AI IDE / AI 设计工具 +- 明确角色分工 +- 统一输入输出规范 + +重点不是“工具越多越好”,而是: + +- 谁负责什么 +- 谁来拍板 +- 谁来 review +- 谁来回归 +- 所有 AI 是否围绕统一文档工作 + +--- + +## 2. 工具分类 + +AI 工具分为 5 类: + +1. 通用大模型 +2. 编码工具 +3. UI / 原型工具 +4. Review / QA 工具 +5. 协作与知识工具 + +--- + +## 3. 工具清单 + +| 类别 | 工具 | 主要用途 | 主要使用者 | 优先级 | 获取方式 | +|------|------|----------|------------|--------|----------| +| 通用大模型 | ChatGPT | 需求整理、方案讨论、文档草稿、总结 | 老大 / 小虾 | P0 | 直接订阅 | +| 通用大模型 | Claude | 长文分析、架构讨论、文档、审查建议 | 老大 / 小虾 | P0 | 直接订阅 | +| 编码工具 | Codex / Codex CLI | 仓库修改、重构、补测试、代码执行 | 小虾 / 编码 AI | P0 | 已可用 / 订阅 | +| 编码工具 | Cursor | 前后端模块实现、快速编码 | 编码 AI | P0 | 直接订阅 | +| 编码工具 | Claude Code / 同类 | 代码实现、重构、排错 | 编码 AI | P1 | 按需开通 | +| UI / 原型 | Figma | 页面结构、流程图、组件设计 | 老大 / UI AI | P0 | 直接订阅 | +| UI / 原型 | Figma AI | 辅助出界面和文案 | UI AI | P1 | Figma 内能力 | +| UI / 原型 | v0 | Web 页面草图、组件原型 | UI AI / 前端 AI | P1 | 直接开通 | +| UI / 原型 | Lovable | 页面快速原型 | UI AI | P2 | 按需开通 | +| Review / QA | 独立大模型会话 | 代码审查、架构偏离检查 | Review AI | P0 | 基于已有模型 | +| Review / QA | 独立大模型会话 | 测试设计、回归建议、bug 复盘 | QA AI | P0 | 基于已有模型 | +| 协作 / 知识 | Gitea | 代码真相源、Issue、PR/提交记录 | 全员 | P0 | 已有 | +| 协作 / 知识 | CI/CD | 自动检查、构建、测试、部署 | 全员 | P0 | 已有基础,需重建 | +| 协作 / 知识 | 巡检机器人 | 线上状态、异常巡检、告警 | 运维 / 小虾 | P1 | 已有基础 | + +--- + +## 4. 按角色分配 + +### 4.1 老大 + +负责: + +- 目标 +- 产品边界 +- 风格偏好 +- 最终拍板 + +建议直接使用: + +- ChatGPT +- Claude +- Figma + +### 4.2 小虾(技术负责人 / 总协调) + +负责: + +- 架构方案 +- 拆解任务 +- 规范约束 +- 合并口径 +- 收口 + +建议直接使用: + +- ChatGPT / Claude +- Codex / Codex CLI +- Gitea +- CI/CD + +### 4.3 编码 AI + +负责: + +- 具体模块实现 +- 前端编码 +- 后端编码 +- 补测试 + +建议直接使用: + +- Codex / Codex CLI +- Cursor +- Claude Code / 同类 + +### 4.4 UI AI + +负责: + +- 页面结构 +- 交互流 +- 原型草图 +- 视觉辅助 + +建议直接使用: + +- Figma +- Figma AI +- v0 +- Lovable + +### 4.5 Review AI + +负责: + +- 代码审查 +- 架构一致性检查 +- 风险提示 +- 边界偏移检查 + +建议直接使用: + +- 独立 ChatGPT / Claude 会话 +- 仓库 diff / PR 内容 + +### 4.6 QA / Bug AI + +负责: + +- 复现问题 +- 补回归场景 +- 测试建议 +- bug 分类 + +建议直接使用: + +- 独立 ChatGPT / Claude 会话 +- CI 报告 +- 日志与监控结果 + +--- + +## 5. 获取顺序 + +### 第一批:马上需要 + +必须先具备: + +1. ChatGPT +2. Claude +3. Cursor 或同类编码 AI IDE +4. Figma +5. Gitea +6. CI/CD 基础 + +### 第二批:开始进入前端原型和多人协作时补 + +1. v0 +2. Figma AI +3. 独立 Review / QA 会话模板 +4. 巡检机器人 SaaS 化巡检能力 + +### 第三批:规模扩大后再补 + +1. 更专业的设计协同工具 +2. 更完整的测试平台 +3. 更高级的知识库 / 检索增强能力 + +--- + +## 6. 采购 / 开通制度 + +### 6.1 原则 + +- 优先采购真正高频使用的工具 +- 一个角色至少有一套稳定主工具 +- 前期不堆太多重复工具 +- 所有付费工具要登记账号归属和用途 + +### 6.2 必须登记的信息 + +每个工具都要记录: + +- 工具名称 +- 购买渠道 +- 账号归属 +- 谁可以使用 +- 费用周期 +- 主要用途 +- 替代方案 + +--- + +## 7. 安全制度 + +### 7.1 严禁随意喂给 AI 的信息 + +- 服务器 root 密码 +- 数据库密码 +- 云存储密钥 +- 支付相关密钥 +- 生产环境敏感配置 +- 用户隐私数据 + +### 7.2 可以给 AI 的信息 + +- 脱敏代码 +- 架构方案 +- 伪造样本数据 +- 通用错误日志(脱敏后) +- 接口设计草稿 + +### 7.3 最佳实践 + +- 敏感配置统一放环境变量和密钥管理中 +- 给外部 AI 前先脱敏 +- 不把完整生产数据库导出直接发给任何模型 + +--- + +## 8. 使用制度 + +### 制度 1:一个问题一个 owner + +- 架构问题只由技术 owner 收口 +- UI 问题由 UI owner 收口 +- Review 不能和编码混同 + +### 制度 2:AI 输出不能直接上线 + +必须经过: + +- 人工确认 +- 代码审查 +- 自动化测试 +- CI 通过 + +### 制度 3:统一文档源 + +所有 AI 都必须围绕同一套文档: + +- PRD +- 架构文档 +- 数据模型 +- API 规范 +- 任务拆解清单 + +### 制度 4:避免多 AI 同时乱改同一问题 + +- 一个任务一个主执行者 +- 其他 AI 提供辅助,不直接平行乱改 + +--- + +## 9. 当前结论 + +当前最值得立即准备的 AI 工具是: + +- ChatGPT +- Claude +- Codex / Codex CLI +- Cursor +- Figma +- Gitea +- CI/CD + +这些足够支撑新 SaaS 项目从 0 到可开发启动。 + +后续再按阶段补更细的 UI / Review / QA 工具。 diff --git a/docs/infrastructure-keep-rebuild-matrix.md b/docs/infrastructure-keep-rebuild-matrix.md new file mode 100644 index 000000000..56a15a4c7 --- /dev/null +++ b/docs/infrastructure-keep-rebuild-matrix.md @@ -0,0 +1,263 @@ +# 基础设施保留 / 废弃 / 重建清单(第一版) + +本文档定义新 SaaS 项目启动时,哪些基础设施继续保留,哪些只保留经验不保留实现,哪些必须全新重建。 + +--- + +## 1. 分类原则 + +基础设施分为三类: + +1. **保留**:直接作为新项目资产继续使用 +2. **废弃实现,仅保留经验**:旧实现不进入新主线,但经验可复用 +3. **必须重建**:新 SaaS 需要重新搭建 + +--- + +## 2. 保留清单 + +### 2.1 代码仓库 + +- **保留项**:Gitea +- **原因**:已经是稳定的代码真相源,具备提交、历史、协作、推送能力 +- **新项目做法**:新 SaaS 使用新的独立仓库或独立主目录,不与旧桌面版混线 + +### 2.2 CI/CD 思路和经验 + +- **保留项**:自动检查、测试、构建、发布的工程理念 +- **原因**:这是正规软件必须保留的工程资产 +- **新项目做法**:重建适合 Web/SaaS 的流水线 + +### 2.3 巡检机器人思路 + +- **保留项**:巡检、告警、状态监控思路 +- **原因**:线上系统比桌面工具更需要健康巡检 +- **新项目做法**:转为 SaaS 服务、任务、队列、磁盘、worker 的巡检 + +### 2.4 服务器与开发环境 + +- **保留项**:Windows 开发机、Ubuntu 服务器 +- **原因**:开发和部署基础已有 +- **新项目做法**:重新按 SaaS 需要规划开发/测试/生产用途 + +--- + +## 3. 旧实现废弃,但经验保留 + +### 3.1 Tkinter 桌面 UI 工程结构 + +- **旧实现**:Tkinter 主窗口 + 单体 GUI 文件 +- **处理方式**:废弃为新主线实现 +- **保留经验**:哪些业务交互复杂、哪些模块最容易耦合 + +### 3.2 安装版同步 / 发布脚本 + +- **旧实现**:`deploy_to_installed.py`、安装版同步链 +- **处理方式**:不带入新 SaaS 主线 +- **保留经验**:发布必须有清单、运行时文件必须明确登记 + +### 3.3 桌面版更新体系 + +- **旧实现**:本地补丁、manifest、rollback +- **处理方式**:不直接复用 +- **保留经验**:版本状态、回滚、失败恢复、补丁安全校验思路 + +### 3.4 临时修复脚本文化 + +- **旧实现**:一批为临时问题而生的脚本 +- **处理方式**:明确不继承到新主线 +- **保留经验**:哪些问题容易高频复发,需要产品级能力解决 + +--- + +## 4. 必须重建的基础设施 + +### 4.1 新项目仓库结构 + +必须重建: + +- 新仓库目录结构 +- 前后端分层结构 +- 文档结构 +- 基础模块布局 + +### 4.2 Web/SaaS CI/CD 流水线 + +必须重建: + +- 前端构建 +- 后端测试 +- 镜像/部署包构建 +- 测试环境部署 +- 正式环境部署 +- 回滚流程 + +### 4.3 鉴权体系 + +必须重建: + +- 用户认证 +- 会话管理 +- 权限控制 +- 团队/工作区边界(如果需要) + +### 4.4 存储体系 + +必须重建: + +- 对象存储 / 素材存储方案 +- 数据库存储方案 +- 结果文件管理方案 +- 清理和归档策略 + +### 4.5 任务体系 + +必须重建: + +- 任务队列 +- 异步生成任务 +- 重试机制 +- 状态追踪 +- worker 管理 + +### 4.6 观测性体系 + +必须重建: + +- 日志 +- 指标 +- 告警 +- 错误追踪 +- 健康检查 + +### 4.7 配置与密钥管理 + +必须重建: + +- 环境变量规范 +- 密钥注入方式 +- 不同环境配置管理 +- 敏感信息隔离 + +--- + +## 5. 项目推进器要不要 + +### 结论 + +要,但先轻量,不要一开始就搞太重。 + +### 轻量方案即可支撑初期 + +- 需求清单 +- 里程碑 +- issue / 任务列表 +- 发布记录 +- 架构文档 + +### 暂时不需要的重型能力 + +- 复杂流程引擎 +- 重度项目管理系统 +- 过度自动化的工单流转 + +--- + +## 6. 巡检机器人要不要 + +### 结论 + +要保留,而且未来价值更大。 + +### 在新 SaaS 中的职责 + +巡检机器人未来可以检查: + +- API 服务是否在线 +- 队列是否堆积 +- worker 是否离线 +- 生成失败率是否异常 +- 存储空间是否异常 +- 数据库连接是否异常 +- 定时任务是否失效 + +### 定位 + +- 它是运维和健康检查系统的一部分 +- 不是业务开发主流程的一部分 + +--- + +## 7. CI/CD 要不要 + +### 结论 + +必须保留,而且必须升级。 + +### 新 SaaS 项目里必须具备的能力 + +- 代码检查 +- 自动测试 +- 构建 +- 测试环境部署 +- 正式环境部署 +- 回滚 + +### 原则 + +- 新项目不能没有 CI/CD +- 但 CI/CD 流程必须服务于 SaaS,不是延用桌面版套路 + +--- + +## 8. 当前建议的基础设施策略 + +### 立即保留 + +- Gitea +- 现有 CI/CD 思路 +- 巡检机器人思路 +- 服务器资源 + +### 立即停止依赖旧实现 + +- 桌面版发布链 +- 安装同步链 +- Tkinter 工程结构 +- 临时修复脚本文化 + +### 接下来优先重建 + +1. 新项目仓库结构 +2. SaaS CI/CD +3. 鉴权体系 +4. 存储体系 +5. 任务体系 +6. 观测性体系 + +--- + +## 9. 当前阶段结论 + +新 SaaS 项目不是从零开始,因为我们有: + +- 仓库 +- CI/CD 经验 +- 巡检经验 +- 服务器 +- 业务规则 +- 稳定性治理经验 + +但新 SaaS 也不能“沿用旧系统实现”,因为: + +- 桌面架构不适合作为未来主线 +- 发布方式完全不同 +- 任务模型完全不同 +- 存储模型完全不同 +- 用户和权限模型未来也会不同 + +所以正确策略是: + +- **保留资产** +- **废弃旧实现** +- **重建 SaaS 所需基础设施** diff --git a/docs/saas-core-flows.md b/docs/saas-core-flows.md new file mode 100644 index 000000000..ac4d85344 --- /dev/null +++ b/docs/saas-core-flows.md @@ -0,0 +1,268 @@ +# SaaS 核心流程清单(第一版) + +本文档定义新 SaaS 系统的一期核心业务流程。目的不是画所有细节,而是先明确系统必须跑通哪些主链路。 + +--- + +## 1. 流程设计原则 + +核心流程必须满足: + +- 覆盖现有桌面版的主要功能价值 +- 适合 SaaS 架构而不是桌面软件思维 +- 支持异步任务、多人协作、后续扩展 +- 允许阶段性交付,不要求一开始全量上线 + +--- + +## 2. 一期核心主流程 + +建议一期至少跑通 6 条主流程: + +1. 登录与进入工作空间 +2. 创建项目 +3. 上传并管理素材 +4. 素材分类与诊断 +5. 发起生成 / 批量生成 +6. 查看结果与下载 + +--- + +## 3. 流程一:登录与进入工作空间 + +### 目标 + +让用户能够进入自己的工作环境。 + +### 基本步骤 + +1. 用户登录 +2. 进入默认工作空间 +3. 查看项目列表 +4. 选择已有项目或创建新项目 + +### 一期建议 + +- 可以先简化为单用户单工作空间 +- 但模型上要保留工作空间概念 + +--- + +## 4. 流程二:创建项目 + +### 目标 + +为一轮视频生成工作建立上下文容器。 + +### 基本步骤 + +1. 用户点击创建项目 +2. 输入项目名称 +3. 系统创建项目 +4. 跳转到项目工作台 + +### 项目工作台未来应承载 + +- 素材库 +- 配音库 +- 策略 +- 任务 +- 结果 +- 诊断 + +--- + +## 5. 流程三:上传并管理素材 + +### 目标 + +把桌面版“本地素材库”的能力转化成 SaaS 中的项目级素材管理。 + +### 子流程 + +#### 5.1 上传视频/图片素材 + +1. 选择项目 +2. 上传文件 +3. 系统保存到素材库 +4. 返回素材列表 + +#### 5.2 上传配音素材 + +1. 选择项目或配音库 +2. 上传配音文件 +3. 系统去重/校验 +4. 返回配音列表 + +#### 5.3 管理素材库 + +包括: + +- 新建素材库 +- 删除素材库 +- 查看素材详情 +- 过滤、搜索、统计 + +### 一期建议 + +- 先做项目级素材上传和列表 +- 库管理可以保留轻量版 + +--- + +## 6. 流程四:素材分类与诊断 + +### 目标 + +复用现有桌面版已经验证过的价值: + +- 自动分类素材 +- 给出素材准备度 +- 找出缺口和风险 + +### 基本步骤 + +1. 用户触发分类/诊断 +2. 系统创建异步任务 +3. worker 执行分类与诊断 +4. 用户看到进度 +5. 分类结果回写素材 +6. 诊断结果展示到项目界面 + +### 关键要求 + +- 长任务异步执行 +- 坏文件不能拖垮整批 +- 分类和诊断结果必须可追踪 + +--- + +## 7. 流程五:发起生成 / 批量生成 + +### 目标 + +这是核心价值流程,必须作为系统最重要主链路设计。 + +### 5.1 单次生成流程 + +1. 用户选择项目 +2. 选择/配置生成策略 +3. 选择素材库与配音库 +4. 提交生成请求 +5. 系统创建生成任务 +6. worker 执行生成 +7. 返回进度和日志 +8. 产出成片结果 + +### 5.2 批量生成流程 + +1. 用户配置批量规则 +2. 提交批量任务 +3. 系统拆分候选 +4. 每个候选独立执行 +5. 单候选失败不拖垮全批次 +6. 汇总成功/失败/跳过结果 + +### 关键要求 + +- 任务必须异步 +- 状态必须可跟踪 +- 单项失败隔离 +- 支持取消 / 停止 / 重试(后续逐步补) + +--- + +## 8. 流程六:查看结果与下载 + +### 目标 + +让用户消费系统产出的最终价值。 + +### 基本步骤 + +1. 用户打开项目结果页 +2. 查看成片列表 +3. 预览结果 +4. 下载单个成片或结果包 + +### 后续可扩展 + +- 一键发布 +- 结果同步 +- 团队复核 +- 版本对比 + +--- + +## 9. 配套支撑流程 + +除了主流程,还需要这些支撑流程: + +### 9.1 任务状态查看 + +- 查看执行中任务 +- 查看失败原因 +- 查看进度 + +### 9.2 日志与诊断 + +- 查看任务日志 +- 查看错误摘要 +- 查看系统健康状态 + +### 9.3 Worker 管理 + +- 节点在线状态 +- 任务分配情况 +- 队列积压情况 + +### 9.4 审计与操作记录 + +- 谁上传了什么 +- 谁删除了什么 +- 谁发起了任务 +- 失败发生在什么时候 + +--- + +## 10. 一期暂缓流程 + +这些不是现在最先必须做的: + +- 支付 / 套餐 +- 模板市场 +- 多层级复杂权限 +- 外部平台自动发布 +- 高级协同审批流 + +这些后面可以做,但不应该阻塞 SaaS 第一版启动。 + +--- + +## 11. 一期主线 MVP 建议 + +建议先确保以下闭环跑通: + +1. 登录 +2. 创建项目 +3. 上传素材 +4. 素材分类 +5. 发起生成 +6. 查看进度 +7. 下载结果 + +如果这条链能稳定跑通,SaaS 第一版就已经有真实价值。 + +--- + +## 12. 当前阶段结论 + +当前最重要的是: + +- 不要试图一口气把所有桌面能力都搬完 +- 先抓住真正的主链路 +- 先让 SaaS 的关键业务闭环成立 + +下一步应继续: + +- 根据核心对象和核心流程,开始做技术栈选型 diff --git a/docs/saas-core-objects.md b/docs/saas-core-objects.md new file mode 100644 index 000000000..85a062049 --- /dev/null +++ b/docs/saas-core-objects.md @@ -0,0 +1,353 @@ +# SaaS 核心对象清单(第一版) + +本文档定义新 SaaS 系统的核心业务对象。目标不是现在就把数据库字段写死,而是先把系统世界观定清楚,避免后续边开发边返工。 + +--- + +## 1. 设计原则 + +核心对象必须满足: + +- 能覆盖当前桌面版的主要业务能力 +- 能支撑未来多人协作和迭代 +- 能清楚区分“谁拥有、谁使用、谁产生、谁消费” +- 不因为桌面版历史实现而被绑死 + +--- + +## 2. 顶层对象分组 + +新 SaaS 的核心对象建议分为 6 组: + +1. 用户与组织 +2. 工作空间与项目 +3. 素材与素材库 +4. 生成与任务 +5. 结果与发布 +6. 系统与运维 + +--- + +## 3. 用户与组织对象 + +### 3.1 User(用户) + +代表系统中的登录使用者。 + +职责: + +- 登录系统 +- 创建项目 +- 上传素材 +- 发起生成 +- 查看结果 +- 管理自己的资源 + +后续待确认: + +- 是否支持多个角色 +- 是否支持子账号 +- 是否支持团队成员 + +### 3.2 Team / Workspace(团队 / 工作空间) + +如果未来不是纯个人 SaaS,就必须有这个对象。 + +职责: + +- 隔离不同用户组的数据 +- 承载成员、项目、素材库、任务 +- 作为权限边界 + +当前建议: + +- 先按“工作空间”概念设计 +- 一期即使只支持单用户,也保留扩展位 + +### 3.3 Membership(成员关系) + +表示用户属于哪个工作空间、具备什么权限。 + +职责: + +- 权限控制 +- 团队协作 +- 后续审计与操作归属 + +--- + +## 4. 工作空间与项目对象 + +### 4.1 Project(项目) + +新 SaaS 里最核心的业务容器之一。 + +职责: + +- 承载一组素材、配音、策略、任务、生成结果 +- 作为用户操作的主要上下文 + +为什么必须有: + +桌面版很多逻辑都默认“当前工作台上下文”,SaaS 里必须把这个上下文对象化。 + +### 4.2 Folder / Collection(可选) + +如果素材量很大,后续可能需要集合/文件夹逻辑。 + +一期建议: + +- 不作为强制核心对象 +- 预留二级组织能力即可 + +--- + +## 5. 素材与素材库对象 + +### 5.1 AssetLibrary(素材库) + +素材库的统一抽象。 + +建议未来不要把“视频库”和“配音库”写成完全不同的两套模型,而是: + +- 统一为 `AssetLibrary` +- 通过 `kind` 区分: + - `video` + - `voice` + - 后续可扩展 `image` / `template` / `subtitle` + +职责: + +- 组织素材 +- 隔离不同项目/工作空间资源 +- 提供筛选、统计、权限、归属能力 + +### 5.2 Asset(素材) + +素材的统一抽象。 + +可能包括: + +- 视频素材 +- 图片素材 +- 配音素材 +- 背景音频 +- 模板文件(未来) + +关键属性层面未来要支持: + +- 所属库 +- 所属项目 / 工作空间 +- 文件地址 +- 文件元数据 +- 分类结果 +- 质量状态 +- 风险标记 +- 使用统计 + +### 5.3 AssetClassification(素材分类结果) + +当前桌面版已经有成熟经验,这个对象未来应该独立表达。 + +职责: + +- 记录素材分类结果 +- 记录口播/场景/待复核等业务标签 +- 作为生成策略选择依据 + +### 5.4 AssetDiagnosis(素材诊断结果) + +用于表达素材准备度、缺口、建议。 + +职责: + +- 给项目/素材库生成健康判断 +- 给用户输出“缺什么、风险在哪里、建议怎么补” + +--- + +## 6. 配音与策略对象 + +### 6.1 VoiceAsset(配音素材) + +虽然可以作为 Asset 的一种,但业务上很重要,需要单独强调。 + +职责: + +- 作为生成输入 +- 支持试听、复核、重复检测、质量检查 + +### 6.2 GenerationStrategy(生成策略) + +桌面版里已经存在生成策略概念,SaaS 里必须保留,而且要对象化。 + +职责: + +- 定义生成模式 +- 定义视频库/配音库选择方式 +- 定义阈值、数量、规则 +- 支持后续模板化与复用 + +### 6.3 StrategyTemplate(策略模板,可选) + +如果未来要做更强复用,这个对象会非常有用。 + +职责: + +- 保存可复用的生成策略 +- 支持团队共享 + +--- + +## 7. 生成与任务对象 + +### 7.1 GenerationRequest(生成请求) + +表示用户点击“开始生成”时提交的一次业务请求。 + +职责: + +- 记录用户发起的意图 +- 绑定项目、策略、素材范围、配置 + +### 7.2 GenerationTask(生成任务) + +表示后台真正执行的一次任务。 + +职责: + +- 追踪状态 +- 分配 worker +- 记录进度 +- 支持失败重试 +- 支持日志与诊断 + +### 7.3 BatchTask(批量任务) + +桌面版已经证明单个任务和批量任务不是一个复杂度级别,所以建议单独对象化。 + +职责: + +- 管理一批生成候选 +- 统计成功/失败/跳过 +- 支持单候选失败隔离 + +### 7.4 TaskAttempt / TaskRun(任务执行记录) + +如果未来要支持重试、回放、错误审计,这个对象很重要。 + +职责: + +- 记录某个任务的第几次执行 +- 记录失败原因、耗时、日志摘要 + +--- + +## 8. 结果与发布对象 + +### 8.1 GeneratedVideo(生成结果 / 成片) + +表示最终产出的业务结果。 + +职责: + +- 保存成片元信息 +- 绑定来源任务 +- 供下载、预览、发布、同步 + +### 8.2 ResultPackage(结果包,可选) + +如果后续要把视频、封面、字幕、元信息打包下载,可以引入。 + +### 8.3 PublishRecord(发布记录,可选) + +如果未来要做发布/分发到外部平台,这个对象需要提前预留。 + +--- + +## 9. 系统与运维对象 + +### 9.1 WorkerNode(执行节点) + +SaaS 版里视频生成大概率不应该和 Web 请求进程混在一起,所以需要执行节点概念。 + +职责: + +- 执行长任务 +- 上报健康状态 +- 接受任务分派 + +### 9.2 SyncJob(同步任务) + +当前桌面版有云同步经验,SaaS 里如果有多存储、多节点、多结果归档,也会需要同步任务抽象。 + +### 9.3 AuditLog(审计日志) + +职责: + +- 记录谁在什么时候做了什么 +- 支持排错、责任追踪、安全审计 + +### 9.4 SystemEvent / HealthCheck(系统事件 / 健康检查) + +职责: + +- 观测性 +- 巡检 +- 告警 + +--- + +## 10. 一期必须优先确认的对象 + +最先必须定死的是: + +1. User +2. Workspace +3. Project +4. AssetLibrary +5. Asset +6. GenerationStrategy +7. GenerationTask +8. BatchTask +9. GeneratedVideo +10. WorkerNode + +这 10 个对象决定了 SaaS 的主骨架。 + +--- + +## 11. 当前建议 + +### 一期推荐采用的对象骨架 + +- 用户 +- 工作空间 +- 项目 +- 统一素材库 +- 统一素材对象 +- 生成策略 +- 单任务 / 批量任务 +- 成片结果 +- Worker 节点 +- 审计日志 + +### 暂时可后补的对象 + +- 套餐 / 支付 +- 发布记录 +- 模板市场 +- 高级权限体系 +- 更复杂的目录树/集合体系 + +--- + +## 12. 当前阶段结论 + +新 SaaS 项目最重要的不是“先建多少表”,而是先把对象世界观定清楚。 + +当前建议已经足够支持下一步: + +- 继续定义核心流程 +- 再根据对象和流程定技术栈 +- 之后再设计数据库和 API diff --git a/docs/saas-development-workflow.md b/docs/saas-development-workflow.md new file mode 100644 index 000000000..97154a448 --- /dev/null +++ b/docs/saas-development-workflow.md @@ -0,0 +1,228 @@ +# SaaS 开发协作流程与制度(第一版) + +本文档定义新 SaaS 项目的开发流程、提交流程、验收流程和 AI 协作制度。 + +目标: + +- 避免边想边改 +- 避免多人同时乱改 +- 避免 AI 输出直接进主线 +- 让每一步都可回溯、可检查、可收口 + +--- + +## 1. 总流程 + +新 SaaS 项目统一按 7 步执行: + +1. 定目标 +2. 写文档 +3. 拆任务 +4. 做设计 +5. 写代码 +6. Review / 测试 +7. 提交 / 发布 + +禁止跳步直接开写。 + +--- + +## 2. 需求到开发的流程 + +### 第 1 步:需求确认 + +由老大提出: + +- 目标 +- 用户对象 +- 边界 +- 验收标准 + +### 第 2 步:小虾整理 + +小虾负责: + +- 转成结构化需求 +- 明确范围 +- 识别依赖 +- 标记风险 + +### 第 3 步:文档先行 + +至少先补以下之一: + +- PRD / 范围文档 +- 架构文档 +- 流程文档 +- 数据模型草稿 +- API 草稿 + +文档不清,不开工。 + +### 第 4 步:任务拆解 + +一个功能必须拆成明确任务,例如: + +- 前端页面 +- API 接口 +- 用例层 +- 数据模型 +- 任务执行链 +- 测试 + +### 第 5 步:实现 + +按既定分工执行: + +- UI AI 先出页面方案 +- 小虾定架构和边界 +- 编码 AI 实现模块 + +### 第 6 步:Review 和测试 + +必须独立执行: + +- Review AI 看架构和风险 +- QA AI 看测试和回归 +- CI 跑自动检查 + +### 第 7 步:收口 + +小虾负责: + +- 汇总变更 +- 检查一致性 +- 确认是否满足验收标准 +- 再执行提交/推送/发布 + +--- + +## 3. 提交流程 + +统一流程: + +1. 开发完成 +2. 本地检查 / 单测 / 必要验证 +3. Review +4. CI 通过 +5. `git add` +6. `git commit` +7. `git push` +8. 记录发布或变更说明 + +铁律: + +- 不能停在 commit +- 没 review / 没测试 / 没 CI 通过的代码不能进主线 + +--- + +## 4. 验收流程 + +每个功能都必须有验收标准。 + +验收至少包含: + +- 功能是否符合目标 +- 是否超出边界 +- 是否破坏架构 +- 是否有测试 +- 是否可回归 +- 是否文档同步 + +如果只是“代码写出来了”,不算完成。 + +--- + +## 5. AI 协作制度 + +### 5.1 分工制度 + +- 老大:产品 owner +- 小虾:技术 owner +- UI AI:页面和交互 +- 编码 AI:具体实现 +- Review AI:代码审查 +- QA AI:测试和回归 + +### 5.2 一个任务一个 owner + +- 一个任务只能有一个主执行者 +- 其他 AI 可以辅助,不能平行乱改 + +### 5.3 架构收口权唯一 + +- 架构方向由小虾收口 +- 老大拍板 +- 其他 AI 不能随意改主方向 + +### 5.4 AI 输出不能直接上线 + +AI 输出必须经过: + +- 人工确认 +- Code Review +- 自动化测试 +- CI 验证 + +--- + +## 6. 分支与变更管理建议 + +如果新项目正式启动,建议采用: + +- `master` / `main`:稳定主线 +- 功能分支:按模块开发 +- 重要改动走 PR / 合并审查 + +如果前期仍是小团队轻量开发,也至少要做到: + +- 每次改动范围明确 +- commit message 清楚 +- 一轮改动一个主题 + +--- + +## 7. 发布流程 + +发布前必须确认: + +- 功能验收通过 +- 回归通过 +- CI 通过 +- 文档更新 +- 日志/监控可用 +- 回滚方式明确 + +发布后必须确认: + +- 服务在线 +- 核心流程在线可用 +- 巡检和告警正常 + +--- + +## 8. 不允许再发生的情况 + +- 边想边改、越改越乱 +- 没有文档直接开工 +- 多个 AI 同时改同一块逻辑 +- AI 代码直接进主线 +- 口头规则替代正式制度 +- 旧桌面版那种临时修复脚本文化带入新项目 + +--- + +## 9. 当前阶段结论 + +新 SaaS 项目从第一天开始就要按正规软件流程走: + +- 先定目标 +- 再写文档 +- 再拆任务 +- 再设计 +- 再实现 +- 再 review / 测试 +- 最后提交 / 发布 + +这样我们后面人再多、AI 再多,也不会重新回到混乱状态。 diff --git a/docs/saas-index.md b/docs/saas-index.md new file mode 100644 index 000000000..87f5d790c --- /dev/null +++ b/docs/saas-index.md @@ -0,0 +1,66 @@ +# SaaS 重建总索引 + +本文档是新 SaaS 项目的总入口。以后所有重建相关文档都从这里进入,避免分散。 + +--- + +## 1. 总目标 + +- 旧桌面版不再作为未来主线 +- 新 SaaS 项目重新开始 +- 先定边界、规矩、工具,再进入开发 +- 所有新项目工作必须新旧隔离 + +--- + +## 2. 核心总文档 + +### 启动与总纲 + +- [SaaS 重建启动方案](saas-rebuild-kickoff-plan.md) +- [SaaS 重建盘点总表](saas-rebuild-inventory.md) + +### 工具与基础设施 + +- [AI 团队工具清单](ai-team-tooling-checklist.md) +- [基础设施保留 / 废弃 / 重建清单](infrastructure-keep-rebuild-matrix.md) + +### 业务与技术 + +- [SaaS 核心对象清单](saas-core-objects.md) +- [SaaS 核心流程清单](saas-core-flows.md) +- [SaaS 技术栈选型表](saas-tech-stack-options.md) + +### 启动范围与工程制度 + +- [SaaS 一期 MVP 范围清单](saas-mvp-scope.md) +- [SaaS 项目目录结构规范](saas-project-structure-spec.md) +- [SaaS 开发协作流程与制度](saas-development-workflow.md) + +--- + +## 3. 阅读顺序 + +建议按这个顺序看: + +1. `saas-rebuild-kickoff-plan.md` +2. `saas-rebuild-inventory.md` +3. `ai-team-tooling-checklist.md` +4. `infrastructure-keep-rebuild-matrix.md` +5. `saas-core-objects.md` +6. `saas-core-flows.md` +7. `saas-tech-stack-options.md` +8. `saas-mvp-scope.md` +9. `saas-project-structure-spec.md` +10. `saas-development-workflow.md` + +--- + +## 4. 当前结论 + +现在已经具备启动 SaaS 重建前最重要的文档骨架。下一步应该进入: + +- 新项目落点确认 +- 仓库/目录方案确认 +- 第一版工程骨架设计 +- 之后才进入真正开发 diff --git a/docs/saas-mvp-scope.md b/docs/saas-mvp-scope.md new file mode 100644 index 000000000..9963a5468 --- /dev/null +++ b/docs/saas-mvp-scope.md @@ -0,0 +1,194 @@ +# SaaS 一期 MVP 范围清单 + +本文档定义新 SaaS 项目的第一阶段最小可用版本(MVP)范围。 + +目标不是把桌面版全部能力一口气搬完,而是先做出一条 **真实可用、能闭环、能上线、能继续扩展** 的主链路。 + +--- + +## 1. MVP 目标 + +一期 MVP 必须实现: + +- 用户能登录进入系统 +- 用户能创建项目 +- 用户能上传素材 +- 系统能完成素材分类 +- 用户能发起生成任务 +- 用户能看到任务进度 +- 用户能拿到生成结果 + +一句话: + +**从“进入系统”到“拿到成片”这条主链必须完整跑通。** + +--- + +## 2. 一期必须做的能力 + +### 2.1 账号与工作空间 + +必须做: + +- 登录 +- 基础用户系统 +- 默认工作空间 + +一期先简化: + +- 单用户 / 单工作空间也可以 +- 但数据模型必须保留未来扩展到团队/多租户的空间 + +### 2.2 项目管理 + +必须做: + +- 创建项目 +- 项目列表 +- 项目详情页 / 工作台入口 + +不必一开始就做: + +- 项目归档 +- 项目模板 +- 复杂项目权限 + +### 2.3 素材管理 + +必须做: + +- 上传视频/图片素材 +- 上传配音素材 +- 查看素材列表 +- 查看配音列表 +- 基础素材库概念 + +必须具备的质量底线: + +- 基础去重/错误校验 +- 基础状态可见 +- 明确的归属(项目 / 工作空间) + +### 2.4 素材分类与诊断 + +必须做: + +- 触发素材分类任务 +- 返回分类结果 +- 能看到素材准备度 / 诊断摘要 + +一期可以简化: + +- 先做基础分类与基础诊断 +- 高级风险评分和复杂建议后续再增强 + +### 2.5 生成任务 + +必须做: + +- 提交单次生成任务 +- 提交批量生成任务 +- 后台异步执行 +- 查询任务状态 +- 查询任务进度 + +必须保留的关键规则: + +- 单项失败不能拖垮整批 +- 长任务不能阻塞主请求 +- 任务必须有状态、日志和失败原因 + +### 2.6 成片结果 + +必须做: + +- 结果列表 +- 结果详情 / 基础信息 +- 下载成片 + +一期可以不做: + +- 一键发布外部平台 +- 高级审核流 +- 结果版本对比 + +--- + +## 3. 一期建议做,但不是强阻塞 + +这些能力建议尽量做,但如果影响整体交付,可以延后到 1.1 或 1.2: + +- 素材筛选与搜索 +- 配音试听 +- 更丰富的诊断报告 +- 任务失败后的手动重试 +- 结果包下载 +- 更细的操作日志 + +--- + +## 4. 一期明确不做的内容 + +以下内容不应阻塞 MVP 启动: + +- 支付 / 套餐系统 +- 模板市场 +- 复杂 RBAC 权限体系 +- 多层级审批流 +- 自动发布到外部平台 +- 超复杂的数据看板 +- 高级团队协作机制 +- 面向外部客户的多租户计费能力 + +这些都是未来功能,不属于 MVP 起步必要条件。 + +--- + +## 5. MVP 验收标准 + +一期 MVP 完成的标准不是“页面很多”,而是以下闭环成立: + +1. 用户登录进入工作空间 +2. 创建项目 +3. 上传视频/图片/配音素材 +4. 触发素材分类 +5. 提交单次或批量生成 +6. 系统后台执行任务 +7. 前端可见任务状态和进度 +8. 用户能查看并下载结果 + +如果这 8 步稳定成立,MVP 就合格。 + +--- + +## 6. MVP 技术验收标准 + +除了功能闭环,还必须满足工程验收: + +- 代码结构符合目标架构 +- 前后端分层清晰 +- 核心接口有文档 +- 核心任务链有自动化测试 +- CI 可跑通 +- 有基础日志和错误追踪 +- 有最小可用部署方案 + +--- + +## 7. 当前建议结论 + +一期 MVP 只抓核心价值: + +- 项目 +- 素材 +- 分类 +- 生成 +- 结果 + +先把主链路做实,再逐步叠加: + +- 团队协作 +- 高级权限 +- 发布能力 +- 运营能力 +- 商业化能力 diff --git a/docs/saas-project-bootstrap-location.md b/docs/saas-project-bootstrap-location.md new file mode 100644 index 000000000..fcbaa90fe --- /dev/null +++ b/docs/saas-project-bootstrap-location.md @@ -0,0 +1,123 @@ +# SaaS 新项目落点与启动方式(第一版) + +本文档定义新 SaaS 项目的物理落点、仓库边界和启动方式。 + +--- + +## 1. 目标 + +新 SaaS 项目必须做到: + +- 与旧桌面版彻底隔离 +- 有单独仓库边界或至少单独根目录边界 +- 可以独立开发、测试、部署 +- 不会把旧实现混进新主线 + +--- + +## 2. 推荐落点策略 + +### 2.1 推荐方案:独立仓库 + 独立目录 + +最稳妥方案是: + +- 新 SaaS 使用独立 Git 仓库 +- 新项目有独立顶层目录 +- 旧桌面版继续保留在当前仓库中作为参考样本 + +### 2.2 推荐目录命名 + +如果在当前机器上先做本地启动,可以使用: + +- `F:\openclaw-saas\`:新项目总目录 +- `F:\openclaw-saas\docs\` +- `F:\openclaw-saas\apps\web\` +- `F:\openclaw-saas\apps\api\` +- `F:\openclaw-saas\apps\worker\` +- `F:\openclaw-saas\packages\domain\` +- `F:\openclaw-saas\packages\application\` +- `F:\openclaw-saas\packages\ports\` +- `F:\openclaw-saas\packages\adapters\` + +### 2.3 推荐仓库命名 + +建议仓库名保持清楚: + +- `xiaoxia-saas` +- 或 `xiaoxia-autocut-saas` + +原则: + +- 名字要能一眼看出是新主线 +- 不要和旧桌面版仓库名字混淆 + +--- + +## 3. 为什么推荐独立仓库 + +### 好处 + +- 物理隔离最彻底 +- 不会把旧桌面版文件误加进来 +- CI/CD、依赖、部署都可以单独设计 +- 后续多人协作更清楚 +- 更符合正规 SaaS 项目的工程实践 + +### 风险 + +- 初期需要重新搭仓库和流水线 +- 需要重新整理文档和工程规范 + +这个成本是值得的,因为它换来的是长期稳定性。 + +--- + +## 4. 启动方式 + +### 4.1 启动前先做的事 + +1. 确认产品边界 +2. 确认一期 MVP +3. 确认技术栈 +4. 确认目录结构 +5. 确认 AI 工具清单 +6. 确认协作制度 + +### 4.2 启动时要先建的东西 + +- 新仓库 +- docs 总目录 +- domain/application/ports/adapters 基础包 +- web/api/worker 三个应用入口 +- CI/CD 初版 +- 测试目录 + +### 4.3 启动时不先做的东西 + +- 不先做复杂权限 +- 不先做支付 +- 不先做多租户高级方案 +- 不先做复杂发布市场 + +--- + +## 5. 与旧桌面版的关系 + +- 旧桌面版继续保留在当前仓库中 +- 旧桌面版不再继续作为未来主线开发 +- 新 SaaS 只参考旧桌面版的业务规则和经验 +- 新 SaaS 不依赖旧桌面版的安装链路和 Tkinter 结构 + +--- + +## 6. 当前建议结论 + +### 最佳启动方案 + +- 独立仓库 +- 独立目录 +- 独立 CI/CD +- 独立文档 +- 独立发布链路 + +这样新项目才能真正从混乱旧代码中脱离出来。 diff --git a/docs/saas-project-structure-spec.md b/docs/saas-project-structure-spec.md new file mode 100644 index 000000000..04c464e56 --- /dev/null +++ b/docs/saas-project-structure-spec.md @@ -0,0 +1,247 @@ +# SaaS 项目目录结构规范(第一版) + +本文档定义新 SaaS 项目的目录结构和职责边界。 + +原则: + +- 新旧隔离 +- 前后端隔离 +- 业务层和框架层隔离 +- 目录一旦定下,后续新增功能必须按结构落位 + +--- + +## 1. 顶层结构建议 + +```text +saas/ + docs/ + apps/ + web/ + api/ + worker/ + packages/ + domain/ + application/ + ports/ + adapters/ + shared/ + infra/ + docker/ + nginx/ + scripts/ + tests/ + integration/ + e2e/ +``` + +--- + +## 2. 目录职责 + +### 2.1 `docs/` + +存放所有正式文档: + +- PRD +- 架构文档 +- 数据模型 +- API 规范 +- 发布说明 +- 运维说明 +- 里程碑和范围文档 + +规则: + +- 文档必须是单一真相源 +- 先改文档,再改实现 + +### 2.2 `apps/web/` + +前端 Web 应用。 + +职责: + +- 页面 +- 路由 +- 组件 +- 页面状态绑定 +- 调用后端 API + +禁止: + +- 在这里写核心业务规则 +- 在这里写任务执行逻辑 + +### 2.3 `apps/api/` + +后端 API 应用。 + +职责: + +- 提供 HTTP API +- 鉴权 +- 请求校验 +- 调用 Application Use Cases + +禁止: + +- 把业务规则直接写在路由层 +- 直接耦合底层外部实现 + +### 2.4 `apps/worker/` + +后台任务执行应用。 + +职责: + +- 执行素材分类任务 +- 执行视频生成任务 +- 执行同步和诊断任务 + +原则: + +- Worker 只执行任务,不处理 Web 交互 + +### 2.5 `packages/domain/` + +核心业务对象和值对象。 + +职责: + +- User +- Workspace +- Project +- Asset +- AssetLibrary +- GenerationTask +- GeneratedVideo +- 业务规则 + +禁止: + +- import Web 框架 +- import 数据库驱动 +- import UI 工具 + +### 2.6 `packages/application/` + +用例层。 + +职责: + +- 创建项目 +- 上传素材 +- 分类素材 +- 发起生成 +- 查询进度 +- 获取结果 + +原则: + +- 编排业务流程 +- 通过 ports 调外部能力 + +### 2.7 `packages/ports/` + +接口定义层。 + +职责: + +- 仓储接口 +- 存储接口 +- 队列接口 +- 通知接口 +- 日志接口 + +原则: + +- 不依赖具体实现 + +### 2.8 `packages/adapters/` + +外部实现层。 + +职责: + +- PostgreSQL 实现 +- Redis / Celery 实现 +- OSS / S3 实现 +- 鉴权实现 +- 日志实现 + +### 2.9 `packages/shared/` + +共享的通用工具和基础类型。 + +职责: + +- 通用配置 +- 错误类型 +- 公共 schema +- 公共常量 + +规则: + +- 只放真正跨层复用的通用内容 +- 禁止把业务逻辑偷塞进 shared + +### 2.10 `infra/` + +基础设施目录。 + +职责: + +- Docker +- Nginx +- 启动脚本 +- 本地开发环境脚本 +- 部署脚本 + +### 2.11 `tests/` + +测试目录。 + +建议拆分: + +- `integration/`:前后端/数据库/队列联调测试 +- `e2e/`:核心流程端到端测试 + +应用/包内部也可各自保留单元测试目录。 + +--- + +## 3. 模块新增规则 + +以后新增功能时,按这个顺序决定落点: + +1. 是业务对象?放 `domain` +2. 是业务用例?放 `application` +3. 是接口定义?放 `ports` +4. 是外部实现?放 `adapters` +5. 是页面或交互?放 `apps/web` +6. 是 API 路由?放 `apps/api` +7. 是后台任务执行?放 `apps/worker` +8. 是部署与运行?放 `infra` + +--- + +## 4. 禁止事项 + +- 禁止把旧桌面版目录结构照搬过来 +- 禁止在 `web` / `api` 中直接写核心业务规则 +- 禁止 `domain` 依赖数据库、Redis、OSS、框架 +- 禁止 `shared` 成为大杂烩 +- 禁止把临时脚本堆进项目根目录 + +--- + +## 5. 当前阶段结论 + +新 SaaS 项目必须从第一天就按这个结构起步。 + +这样做的价值是: + +- 不会再回到单体混乱结构 +- AI 工具更容易分工协作 +- 后面扩功能、拆模块、加 worker 都更顺 diff --git a/docs/saas-rebuild-inventory.md b/docs/saas-rebuild-inventory.md new file mode 100644 index 000000000..fdda31563 --- /dev/null +++ b/docs/saas-rebuild-inventory.md @@ -0,0 +1,354 @@ +# 小虾 SaaS 重建盘点总表(第一版) + +本文档用于回答两个最核心的问题: + +1. 我们现在已经有什么? +2. 我们为了启动新的 SaaS 项目,还缺什么? + +目的不是罗列名词,而是为后续 **产品边界、技术选型、架构设计、AI 协作分工、开发排期** 提供真实依据。 + +--- + +## 1. 当前已有资产总览 + +当前资产分为 5 类: + +1. 业务资产 +2. 工程资产 +3. 基础设施资产 +4. AI 协作资产 +5. 人和流程资产 + +这些是我们重建 SaaS 时真正可以利用的起点。 + +--- + +## 2. 业务资产盘点 + +### 2.1 已有功能样本 + +现有桌面版已经覆盖了未来 SaaS 需要继承的主要业务能力: + +- 素材库管理 +- 配音库管理 +- 素材分类 +- 素材缺口诊断 +- 视频生成 +- 批量生成 +- 云同步 +- 更新治理 +- 错误治理 +- 稳定性治理 + +### 2.2 已沉淀的业务规则 + +这些规则以后不一定直接复用代码,但应该复用为 SaaS 业务规则或用例设计输入: + +- 坏素材文件必须隔离,不能拖垮整批扫描 +- 单个候选生成失败不能拖垮整个批次 +- 长任务必须异步执行,不能阻塞 UI / 主流程 +- 更新必须可回滚、可记录状态 +- 主流程必须有错误诊断和恢复能力 +- 核心业务逻辑比表面覆盖率数字更重要 + +### 2.3 现有业务理解深度 + +我们已经搞清楚了这些关键业务对象和关系: + +- 视频素材 +- 配音素材 +- 素材库 +- 配音库 +- 生成策略 +- 批量生成任务 +- 生成结果 +- 云端同步对象 +- 更新版本 / 补丁包 +- 错误诊断记录 + +这意味着新 SaaS 不是从“需求不清楚”开始,而是从“实现要重做,但业务已经摸透”开始。 + +--- + +## 3. 工程资产盘点 + +### 3.1 现有代码资产 + +旧桌面版代码虽然不再作为未来主线,但仍然是非常重要的参考资产: + +- 它提供了完整功能样本 +- 它暴露了真实复杂度和真实坑点 +- 它帮助我们识别哪些模块是高风险区 +- 它为 SaaS 重建提供业务规则、异常分支、边界条件参考 + +### 3.2 已有架构资产 + +虽然旧系统的最终实现不一致,但我们已经沉淀出一些正确方向: + +- `Domain → Repository → Service → Facade` 的分层意识 +- `Controller / View` 分离意识 +- `EventBus` 的组件解耦尝试 +- `Ports / Adapters / Application / Presentation` 新基线目录已经建立 + +### 3.3 已有测试资产 + +当前旧项目已经有一定测试基础: + +- controller 测试 +- service 测试 +- repository 测试 +- domain 测试 +- update rollback 测试 +- startup guard 测试 +- material isolation 测试 + +这些测试未来不一定直接复用,但可以帮助我们: + +- 提取业务规则 +- 识别边界条件 +- 设计 SaaS 版测试策略 + +--- + +## 4. 基础设施资产盘点 + +### 4.1 当前已经有的基础设施 + +- **代码仓库**:Gitea +- **CI 基础**:已有 CI 经验和工作流雏形 +- **巡检机器人**:已有自动巡检思路和脚本基础 +- **开发环境**:Windows 开发机 + Ubuntu 服务器 +- **发布/同步经验**:已有正式版同步、回滚、状态记录经验 + +### 4.2 这些资产的保留策略 + +#### 必须保留 + +- Gitea +- CI/CD 思路 +- 巡检机器人 +- 服务器资源 +- 版本管理流程 + +#### 只保留经验,不保留实现 + +- Tkinter 桌面版安装发布链 +- 桌面版同步脚本体系 +- 旧桌面版兼容逻辑 +- 临时修复脚本文化 + +--- + +## 5. AI 协作资产盘点 + +### 5.1 当前已经具备的 AI 协作方式 + +- 已经形成“你拍板、小虾收口”的稳定协作模式 +- 已经知道 AI 不能一个人包完所有角色 +- 已经明确未来需要分角色使用 AI: + - 架构 + - UI + - 编码 + - Review + - QA + - 文档 + +### 5.2 当前还没有真正落地的 AI 团队能力 + +还缺: + +- 明确的 AI 工具采购清单 +- 明确的 AI 账号归属 +- 明确的角色与工具映射 +- 明确的 AI 输入输出规范 +- 明确的安全边界(密钥、服务器、数据库等) + +--- + +## 6. 人和流程资产盘点 + +### 6.1 当前团队形态 + +本质上还是: + +- 你:产品负责人 +- 小虾:技术负责人 / 协调者 +- 其他 AI:按角色配合 + +这是一个“小团队”模式,所以新系统必须遵守两个现实: + +- 技术栈不能过重 +- 流程不能过重 + +### 6.2 当前已有流程经验 + +我们已经明确过的重要工程流程: + +- 源码只在单一源目录修改 +- 修改后先验证 +- 再同步正式版 +- 再确认 +- 再 `git add / commit / push` +- 不能停在 commit + +这个流程意识未来可以保留,但要升级为 SaaS 项目的正式流程。 + +--- + +## 7. 当前最核心的缺口 + +下面这些是现在真正缺少的,而且不补就不能稳启动 SaaS 项目。 + +### 7.1 产品边界缺口 + +还没最终拍板: + +- SaaS 面向谁 +- 单用户还是团队使用 +- 是否多租户 +- 是否有工作区概念 +- 是否要权限系统 +- 是否要付费/套餐系统 + +### 7.2 系统边界缺口 + +还没最终拍板: + +- 素材文件存储在哪里 +- 视频生成在什么节点执行 +- 是否需要分布式 worker +- 是否有对象存储 / OSS / S3 +- 是否支持多设备 / 多节点协同 + +### 7.3 技术选型缺口 + +还没确认: + +- 前端框架 +- 后端框架 +- 数据库 +- 队列系统 +- 任务调度方式 +- 鉴权方式 +- 部署方式 +- 日志/监控方案 + +### 7.4 工程制度缺口 + +还没正式落地: + +- SaaS 项目目录结构 +- API 规范 +- 数据模型规范 +- 提交流程 +- 分支策略 +- 测试规范 +- 发布规范 +- 回滚规范 + +### 7.5 AI 团队工具缺口 + +还没确认和落地: + +- 编码 AI 用哪些 +- UI AI 用哪些 +- Review AI 用哪些 +- QA AI 用哪些 +- 工具怎么购买 / 开通 / 管理 +- 哪些人/角色使用哪些工具 + +--- + +## 8. 当前最应该保留的东西 + +### 必须保留 + +- 业务规则和业务理解 +- Gitea 仓库体系 +- CI/CD 思路 +- 巡检/健康检查能力 +- 稳定性治理经验 +- 测试意识 +- 版本与回滚意识 + +### 必须放弃 + +- 继续在旧桌面版上做主线重构 +- 临时脚本救火式开发 +- 架构半拉子状态 +- 一边想边改、一边改一边返工 + +--- + +## 9. 下一步盘点顺序 + +现在不应该直接进入代码开发。 + +接下来建议按这个顺序盘: + +### 第 1 张表:我们现在有什么(当前文档) + +已开始。 + +### 第 2 张表:我们还缺什么 + +在当前文档中已经列出核心缺口,但还需要进一步定级: + +- 必须先有 +- 可以后补 +- 暂时不做 + +### 第 3 张表:AI 团队工具清单 + +需要补充: + +- 工具名称 +- 用途 +- 谁用 +- 优先级 +- 获取方式 +- 是否立即需要 + +### 第 4 张表:基础设施保留 / 废弃 / 重建清单 + +需要明确: + +- 哪些沿用 +- 哪些只保留经验 +- 哪些必须全新搭建 + +--- + +## 10. 当前阶段的结论 + +### 我们现在真正拥有的,不只是代码 + +我们拥有: + +- 完整业务样本 +- 已知复杂度 +- 已知坑点 +- 已知异常处理经验 +- 已有工程基础设施 +- 已有协作方式 + +### 我们现在真正缺的,也不是代码 + +我们缺: + +- 清晰边界 +- 明确规则 +- 技术选型 +- AI 工具体系 +- 正式工程制度 + +### 所以下一步的重点 + +不是“赶紧开工写 SaaS 代码”,而是: + +- 继续把盘点做全 +- 把边界定死 +- 把工具准备好 +- 把制度落成文档 + +只有这样,新的 SaaS 项目才能真正轻装上阵,而不是把旧系统的问题带过去。 diff --git a/docs/saas-rebuild-kickoff-plan.md b/docs/saas-rebuild-kickoff-plan.md new file mode 100644 index 000000000..4f91e4e27 --- /dev/null +++ b/docs/saas-rebuild-kickoff-plan.md @@ -0,0 +1,577 @@ +# 小虾 SaaS 重建启动方案 + +## 1. 背景与结论 + +当前桌面版代码已经不适合作为未来主线继续演进。 + +核心结论: + +- 旧桌面版停止作为未来主线,不再继续做架构性重构。 +- 新系统按 **SaaS 版** 重新开始开发。 +- 目标不是“在旧系统上修补”,而是按正规软件工程方式做一个长期可维护、可扩展、可迭代的新系统。 +- 旧代码只作为 **业务参考样本**、**规则参考**、**流程参考**,不再作为主开发容器。 +- 新旧代码必须彻底隔离,避免再次混乱。 + +## 2. 总目标 + +做一个全新的 SaaS 系统,业务功能覆盖当前桌面版的核心能力: + +- 素材库管理 +- 配音库管理 +- 素材分类与诊断 +- 视频生成 +- 批量生成 +- 云同步 +- 更新/版本机制(未来按 SaaS 方式重新设计) +- 任务进度与结果管理 + +目标要求: + +- 可长期迭代 +- 可多人协作开发 +- 可上线部署 +- 可观察、可回滚、可测试 +- 不再回到“哪里报错改哪里”的状态 + +## 3. 基本原则 + +### 3.1 先定规则,再开始 + +在新项目开始编码前,必须先确认: + +- 产品边界 +- 一期范围 +- 核心对象 +- 核心流程 +- 技术栈 +- 架构分层 +- 开发规范 +- 发布规范 +- AI 团队协作方式 + +### 3.2 新旧彻底隔离 + +- 旧桌面版代码继续保留,但只作参考 +- 新 SaaS 项目必须在全新目录中开始 +- 禁止把旧桌面版代码直接混入新系统主线 +- 旧系统的安装脚本、Tkinter 逻辑、桌面发布链路不带入新主线 + +### 3.3 先盘点,再设计,再开发 + +执行顺序必须固定: + +1. 盘点现状 +2. 确认边界 +3. 明确规则 +4. 设计架构 +5. 搭项目骨架 +6. 分模块开发 +7. Review / 测试 / 部署 + +## 4. 新系统目标架构 + +采用: + +- **Clean Architecture** +- **MVVM / Presenter** +- **Ports / Adapters** + +目标分层: + +```text +Domain + ↑ +Application / Use Cases + ↑ +Ports + ↑ +Adapters + ↑ +Presentation (Presenter / ViewModel) + ↑ +Web UI / API / Worker +``` + +原则: + +- 界面不写业务逻辑 +- 业务逻辑不依赖框架 +- 外部能力通过接口接入 +- 任务、文件、存储、云服务全部可替换 + +## 5. 我们现在已有的资产 + +### 5.1 业务资产 + +- 现有桌面版就是最完整的业务样本 +- 已经沉淀出大量业务规则和异常治理经验 +- 已经明确的功能模块: + - 素材库 + - 配音库 + - 批量生成 + - 云同步 + - 素材分类 + - 缺口诊断 + - 更新治理 + - 稳定性治理 + +### 5.2 工程资产 + +- Gitea 代码仓库 +- CI / 基础自动化能力 +- 巡检机器人 +- 项目推进经验 +- 既有测试体系雏形 +- 架构规约基础文档 + +### 5.3 人和协作资产 + +- 你负责产品方向和最终拍板 +- 小虾负责技术架构、拆解、规范、收口 +- 已经形成连续迭代经验 + +## 6. 我们明显还缺的东西 + +### 6.1 产品边界 + +还需要明确: + +- SaaS 面向谁 +- 是个人 SaaS 还是团队 SaaS +- 是否多租户 +- 是否有角色权限系统 +- 是否有套餐/支付 + +### 6.2 系统边界 + +还需要明确: + +- 素材文件存在哪里 +- 视频生成在哪里跑 +- 任务如何调度 +- 节点/worker 怎么管理 +- 哪些功能是一期,哪些延后 + +### 6.3 技术边界 + +还需要明确: + +- 前端框架 +- 后端框架 +- 数据库 +- 队列系统 +- 对象存储 +- 鉴权方式 +- 部署方式 +- 监控和日志方案 + +## 7. AI 团队工具怎么来 + +### 7.1 原则 + +前期 **不自研 AI 平台**。 + +策略: + +- 直接购买/使用现成 AI 工具 +- 我们自己做的是协作机制、流程和规则 +- 工具是能力来源,制度才是稳定产出的关键 + +### 7.2 工具来源分类 + +#### 编码类 AI + +用于: + +- 写代码 +- 重构 +- 补测试 +- 查仓库 +- 做具体模块实现 + +候选工具: + +- Codex / Codex CLI +- Claude Code +- Cursor +- Windsurf + +#### 通用大模型类 + +用于: + +- 需求整理 +- 架构讨论 +- 文档撰写 +- 方案对比 +- 总结与沟通 + +候选工具: + +- ChatGPT +- Claude +- Gemini + +#### UI / 原型类 + +用于: + +- 页面草图 +- 交互流程 +- 组件原型 +- 设计样稿 + +候选工具: + +- Figma +- Figma AI +- v0 +- Lovable + +#### 协作 / 自动化基础设施 + +用于: + +- 代码托管 +- 持续集成 +- 告警巡检 +- 发布流程 +- 项目推进 + +保留资产: + +- Gitea +- CI/CD +- 巡检机器人 +- 轻量项目推进机制 + +### 7.3 工具获取顺序 + +第一批先具备: + +1. ChatGPT / Claude +2. Cursor 或同类编码 AI IDE +3. Figma +4. 保留现有 Gitea / CI / 巡检机器人 + +第二批按需要补: + +1. v0 / Lovable +2. 专门 Review / QA 的 AI 会话体系 +3. 更完整的测试辅助和流程自动化工具 + +### 7.4 工具不是越多越好 + +关键不是堆模型,而是: + +- 谁负责什么 +- 谁来拍板 +- 谁来 review +- 谁来回归 +- 所有人是否围绕同一套真相文档工作 + +## 8. AI 团队角色分工 + +### 8.1 人类角色 + +#### 你(产品负责人) + +负责: + +- 方向 +- 目标 +- 优先级 +- 体验偏好 +- 最终拍板 + +#### 小虾(技术负责人 / 总协调) + +负责: + +- 架构方案 +- 任务拆解 +- 规范制定 +- 边界约束 +- 技术决策收口 +- 最终合并口径 + +### 8.2 AI 角色 + +#### UI 设计 AI + +负责: + +- 页面结构 +- 交互流 +- 页面草图 +- 视觉方案辅助 + +#### 编码 AI + +负责: + +- 前端实现 +- 后端实现 +- 基础模块实现 +- 脚手架和具体模块开发 + +#### Review AI + +负责: + +- 代码审查 +- 风险检查 +- 架构偏移检查 +- 规范一致性检查 + +#### QA / Bug AI + +负责: + +- 复现问题 +- 补测试 +- 回归验证 +- 测试建议 + +#### 文档 AI + +负责: + +- PRD 草稿 +- 接口文档 +- 架构文档 +- 发布说明 +- 迁移说明 + +### 8.3 协作铁律 + +- 一个问题只有一个 owner +- 架构只能由一个统一 owner 维护 +- 写代码的人不能代替 Review +- 修 bug 的人不能代替 QA +- 所有 AI 输出必须围绕统一文档体系 + +## 9. 现有基础设施去留 + +### 9.1 保留 + +- 代码仓库(Gitea) +- CI/CD 理念和流程能力 +- 巡检机器人 +- 项目推进机制(轻量化) + +### 9.2 不照搬 + +- 旧桌面版安装/同步/发布链路 +- Tkinter 相关工程结构 +- 旧系统临时修补脚本文化 +- 为兼容旧桌面版而存在的特殊逻辑 + +### 9.3 新系统要重建的基础设施 + +- 新项目仓库结构 +- 新 CI/CD 流水线 +- 新日志/监控/告警规范 +- 新部署流程 +- 新测试流程 + +## 10. 每一步怎么走 + +### 第 1 步:盘点现状 + +盘 4 张表: + +1. 我们现在已有的功能 +2. 我们已有的工程资产 +3. 我们已有的工具 +4. 我们缺少的能力和基础设施 + +### 第 2 步:确认产品边界 + +确认: + +- SaaS 给谁用 +- 单人还是团队 +- 多租户是否需要 +- 一期和后续范围怎么划分 + +### 第 3 步:确认核心对象 + +确认是否保留和如何设计: + +- 用户 +- 团队 / 工作区 +- 项目 +- 素材库 +- 配音库 +- 生成任务 +- 成片 +- 任务节点 / worker +- 版本 / 发布对象 + +### 第 4 步:确认核心流程 + +要把这些完整走通: + +- 上传素材 +- 素材分类 +- 配音管理 +- 生成任务 +- 批量生成 +- 结果查看 +- 下载/发布 +- 后台任务监控 + +### 第 5 步:确认技术栈 + +需要明确: + +- 前端 +- 后端 +- 数据库 +- 队列 +- 对象存储 +- 鉴权 +- 部署方式 +- 监控日志 + +### 第 6 步:确认制度 + +包括: + +- 目录结构 +- 代码规范 +- 接口规范 +- 数据模型规范 +- 提交流程 +- 发布流程 +- AI 协作流程 + +### 第 7 步:定义 MVP + +列出: + +- 一期必须做什么 +- 可以延后什么 +- 明确不做什么 + +### 第 8 步:再开始搭骨架 + +包括: + +- 新项目目录 +- 前后端工程骨架 +- 数据库迁移体系 +- CI/CD 初版 +- 日志与配置体系 + +### 第 9 步:模块化开发 + +严格按架构和边界执行。 + +### 第 10 步:Review / QA / 上线 + +必须经过: + +- Code Review +- 自动化测试 +- 回归验证 +- 预发布验证 +- 正式发布与监控 + +## 11. 制度怎么落实 + +### 制度 1:先文档后开发 + +没有边界、架构、一期范围确认,不开工。 + +### 制度 2:单一真相源 + +必须建立统一文档源: + +- PRD +- 架构文档 +- 数据模型 +- API 文档 +- 里程碑 + +### 制度 3:架构 owner 唯一 + +- 架构由小虾负责收口 +- 你负责最终确认 +- 其他 AI 不得随意改方向 + +### 制度 4:AI 不直接上线 + +AI 产出必须经过: + +- Review +- 测试 +- CI +- 验收 + +### 制度 5:禁止临时修补进入主线 + +- 所有变更必须走既定流程 +- 不允许口头规则替代正式规则 + +### 制度 6:一次只处理一个阶段问题 + +- 先定需求 +- 再定架构 +- 再定页面 +- 再开发 +- 再测试 + +### 制度 7:发布必须可回滚 + +- 每次上线必须可回滚 +- 必须有监控与告警 + +### 制度 8:新旧彻底隔离 + +- 旧项目只参考 +- 新项目主线独立开发 + +## 12. 当前最重要的下一步 + +不是写 SaaS 代码,而是先盘点: + +### 第一张表:我们现在有什么 + +需要逐项盘清: + +- 现有功能 +- 现有工具 +- 现有基础设施 +- 可复用的业务规则 +- 缺少的能力 + +只有这一步做清楚,后面技术选型、架构设计、AI 分工、项目骨架才不会跑偏。 + +## 13. 当前状态结论 + +我们现在已经统一了方向: + +- 旧桌面版不再作为未来主线 +- 新 SaaS 项目全新开始 +- 必须正规化开发 +- 必须先盘点、先定边界、先定制度 +- AI 工具采用现成能力 + 明确分工,不自研底座 +- 现有 Gitea / CI / 巡检机器人保留,但未来服务于新系统 + +下一步进入: + +**《SaaS 重建盘点总表》第一阶段:我们现在有什么、还缺什么。** + +配套文档: + +- `docs/saas-rebuild-inventory.md` +- `docs/ai-team-tooling-checklist.md` +- `docs/infrastructure-keep-rebuild-matrix.md` +- `docs/saas-core-objects.md` +- `docs/saas-core-flows.md` +- `docs/saas-tech-stack-options.md` +- `docs/saas-mvp-scope.md` +- `docs/saas-project-structure-spec.md` +- `docs/saas-development-workflow.md` diff --git a/docs/saas-tech-stack-options.md b/docs/saas-tech-stack-options.md new file mode 100644 index 000000000..093c89924 --- /dev/null +++ b/docs/saas-tech-stack-options.md @@ -0,0 +1,274 @@ +# SaaS 技术栈选型表(第一版) + +本文档用于明确新 SaaS 项目应该采用什么技术栈,以及为什么这样选。 + +当前阶段的原则不是追求“最新最炫”,而是追求: + +- 稳定 +- 清晰 +- 好维护 +- 适合两人 + AI 协作 +- 便于长期迭代 + +--- + +## 1. 选型原则 + +### 必须满足 + +- 简单清晰 +- 社区成熟 +- 适合视频/任务型系统 +- 适合长期演进 +- 对 AI 编码工具友好 +- 前后端边界明确 + +### 尽量避免 + +- 过重的企业级复杂框架组合 +- 需要大量人力维护的微服务架构 +- 前期就引入过多中间件 +- 因“先进”而牺牲可维护性 + +--- + +## 2. 前端建议 + +### 推荐方向 + +- **React + TypeScript + Next.js(管理台 / Web 前端)** + +### 原因 + +- 生态成熟 +- AI 工具支持好 +- 组件化能力强 +- 适合后台系统和 SaaS 管理界面 +- 后续做 SSR / 管理台 / 仪表盘都顺手 + +### UI 组件建议 + +- `shadcn/ui` 或同类现代组件体系 +- 配合 Tailwind CSS + +### 结论建议 + +前端推荐: + +- `Next.js + TypeScript + Tailwind + shadcn/ui` + +--- + +## 3. 后端建议 + +### 推荐方向 + +- **Python + FastAPI** + +### 原因 + +- 和现有业务逻辑语言认知一致 +- 适合 AI 工具协作 +- 非常适合 API + 后台任务系统 +- 类型标注、文档、异步支持都成熟 +- 对视频处理、脚本、任务调度的生态友好 + +### 不建议前期改成 Node 作为主后端的原因 + +- 现有业务规则和视频处理经验都更接近 Python 生态 +- 后续 worker、ffmpeg、诊断逻辑、脚本集成都更自然 + +### 结论建议 + +后端推荐: + +- `FastAPI + Python` + +--- + +## 4. 数据库建议 + +### 推荐方向 + +- **PostgreSQL** + +### 原因 + +- 稳定成熟 +- 非常适合 SaaS 主业务数据 +- 对 JSON 字段、索引、事务支持好 +- 生态完整 + +### 结论建议 + +主数据库: + +- `PostgreSQL` + +--- + +## 5. 异步任务 / 队列建议 + +### 推荐方向 + +- **Celery + Redis** + +### 原因 + +- 视频生成、素材分类、同步、诊断都属于典型长任务 +- 需要 worker、状态、重试、任务分离 +- Python 生态成熟 + +### 可替代方向 + +- RQ / Dramatiq / 自建轻量任务层 + +### 当前建议 + +如果追求成熟和可扩展,优先: + +- `Celery + Redis` + +--- + +## 6. 对象存储建议 + +### 推荐方向 + +- **S3 兼容对象存储 / OSS** + +### 原因 + +- SaaS 素材和结果文件不适合长期直接绑本地磁盘 +- 对象存储天然适合大文件、归档、分发 +- 便于后续多 worker / 多环境扩展 + +### 当前建议 + +抽象成统一存储接口,底层可接: + +- 阿里云 OSS +- MinIO +- S3 兼容存储 + +--- + +## 7. 鉴权建议 + +### 推荐方向 + +- **JWT + Session/Refresh Token 组合** + +### 原因 + +- 适合 Web SaaS +- 前后端分离支持好 +- 后续权限系统扩展方便 + +### 当前建议 + +一期先做: + +- 登录 +- 用户身份校验 +- 工作空间边界 + +高级权限后续再扩。 + +--- + +## 8. 部署建议 + +### 推荐方向 + +- **Docker Compose 起步,后续再升级** + +### 原因 + +- 我们当前团队规模不适合一开始上 Kubernetes +- Compose 足够支撑首版和测试环境 +- 后续如果规模上来,再考虑更复杂调度 + +### 初期部署对象 + +- Web 前端 +- FastAPI 后端 +- PostgreSQL +- Redis +- Worker +- Nginx / 反向代理 + +--- + +## 9. 日志与监控建议 + +### 一期最小配置 + +- 应用日志 +- 任务日志 +- 错误追踪 +- 健康检查 +- 巡检机器人 + +### 后续建议 + +- Sentry(错误追踪) +- Prometheus + Grafana(指标) +- Loki / ELK(日志检索,按需要) + +--- + +## 10. 开发规范建议 + +### 前端 + +- TypeScript 强制 +- ESLint / Prettier +- 组件边界清晰 +- 页面逻辑与数据逻辑分离 + +### 后端 + +- 类型标注 +- Pydantic / schema 明确 +- 分层结构清晰 +- 用例层和适配器层分开 + +### 测试 + +- 单元测试 +- API 测试 +- 任务流关键路径测试 +- CI 自动执行 + +--- + +## 11. 当前推荐组合(第一版) + +### 推荐首选方案 + +- **前端**:Next.js + TypeScript + Tailwind + shadcn/ui +- **后端**:FastAPI + Python +- **数据库**:PostgreSQL +- **队列**:Redis + Celery +- **存储**:S3/OSS 兼容对象存储 +- **部署**:Docker Compose +- **监控**:巡检机器人 + 基础日志 + 后续接 Sentry + +这是我现在最推荐的首版 SaaS 技术栈。 + +--- + +## 12. 当前阶段结论 + +对于我们这种“两个人 + AI 工具”的实际情况,最重要的不是用最潮的栈,而是: + +- 选成熟的 +- 选 AI 容易协作的 +- 选长期不容易后悔的 +- 选和视频任务系统天然契合的 + +当前推荐方向已经足够支撑下一步: + +- 正式定义 SaaS 项目目录结构 +- 开始搭第一版工程骨架 diff --git a/infra/docker/api.Dockerfile b/infra/docker/api.Dockerfile new file mode 100644 index 000000000..319ad6bb3 --- /dev/null +++ b/infra/docker/api.Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +ENV PYTHONPATH=/app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY apps/api /app/apps/api +COPY packages /app/packages +WORKDIR /app/apps/api +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/infra/docker/compose.yml b/infra/docker/compose.yml new file mode 100644 index 000000000..b26001f64 --- /dev/null +++ b/infra/docker/compose.yml @@ -0,0 +1,55 @@ +version: '3.9' +services: + web: + image: node:20 + working_dir: /app/apps/web + command: sh -c "npm install && npm run dev" + ports: + - "3000:3000" + volumes: + - ../..:/app + depends_on: + - api + + api: + image: xiaoxia-saas-api:dev + build: + context: ../.. + dockerfile: infra/docker/api.Dockerfile + env_file: + - ../../.env.example + ports: + - "8000:8000" + depends_on: + - postgres + - redis + + worker: + image: xiaoxia-saas-worker:dev + build: + context: ../.. + dockerfile: infra/docker/worker.Dockerfile + env_file: + - ../../.env.example + depends_on: + - redis + - postgres + + postgres: + image: postgres:16 + environment: + POSTGRES_DB: xiaoxia_saas + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7 + ports: + - "6379:6379" + +volumes: + postgres_data: diff --git a/infra/docker/worker.Dockerfile b/infra/docker/worker.Dockerfile new file mode 100644 index 000000000..1e5be66d1 --- /dev/null +++ b/infra/docker/worker.Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +ENV PYTHONPATH=/app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY apps/worker /app/apps/worker +COPY packages /app/packages +WORKDIR /app/apps/worker +CMD ["celery", "-A", "worker_app.celery_app.celery_app", "worker", "--loglevel=info"] diff --git a/infra/nginx/default.conf b/infra/nginx/default.conf new file mode 100644 index 000000000..868810edf --- /dev/null +++ b/infra/nginx/default.conf @@ -0,0 +1,12 @@ +server { + listen 80; + server_name _; + + location /api/ { + proxy_pass http://api:8000/; + } + + location / { + return 200 'xiaoxia-saas nginx scaffold'; + } +} diff --git a/infra/scripts/bootstrap-notes.md b/infra/scripts/bootstrap-notes.md new file mode 100644 index 000000000..bafe5bc31 --- /dev/null +++ b/infra/scripts/bootstrap-notes.md @@ -0,0 +1,16 @@ +# Bootstrap notes + +Current scaffold status: + +- `apps/web`: Next.js-style skeleton files created +- `apps/api`: FastAPI package layout created (`app/core`, `app/api`, `app/schemas`) +- `apps/worker`: Celery worker package layout created (`worker_app/core`, `worker_app/tasks`) +- `infra/docker/compose.yml`: local dev compose with web/api/worker/postgres/redis + +Next actions: + +1. install frontend dependencies +2. add first real domain models in `packages/domain` +3. add first application use cases in `packages/application` +4. connect API routes to use cases +5. add CI workflow diff --git a/package.json b/package.json new file mode 100644 index 000000000..c309d74df --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "xiaoxia-saas", + "private": true, + "workspaces": [ + "apps/web" + ], + "scripts": { + "dev:web": "npm --workspace apps/web run dev", + "build:web": "npm --workspace apps/web run build", + "lint:web": "npm --workspace apps/web run lint", + "lint": "npm run lint:web", + "test": "echo TODO: add frontend/backend tests" + } +} diff --git a/packages/__init__.py b/packages/__init__.py new file mode 100644 index 000000000..fce7667b8 --- /dev/null +++ b/packages/__init__.py @@ -0,0 +1 @@ +"""Packages root.""" diff --git a/packages/adapters/__init__.py b/packages/adapters/__init__.py new file mode 100644 index 000000000..8a5141254 --- /dev/null +++ b/packages/adapters/__init__.py @@ -0,0 +1 @@ +"""Adapters package for external implementations.""" diff --git a/packages/adapters/in_memory/__init__.py b/packages/adapters/in_memory/__init__.py new file mode 100644 index 000000000..64745a1da --- /dev/null +++ b/packages/adapters/in_memory/__init__.py @@ -0,0 +1,11 @@ +from .asset_library_repository import InMemoryAssetLibraryRepository +from .asset_repository import InMemoryAssetRepository +from .ingest_job_repository import InMemoryIngestJobRepository +from .project_repository import InMemoryProjectRepository + +__all__ = [ + "InMemoryAssetLibraryRepository", + "InMemoryAssetRepository", + "InMemoryIngestJobRepository", + "InMemoryProjectRepository", +] diff --git a/packages/adapters/in_memory/asset_library_repository.py b/packages/adapters/in_memory/asset_library_repository.py new file mode 100644 index 000000000..5ae2eaf23 --- /dev/null +++ b/packages/adapters/in_memory/asset_library_repository.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from packages.domain import AssetLibrary, AssetLibraryKind + + +class InMemoryAssetLibraryRepository: + def __init__(self): + self._items: dict[str, list[AssetLibrary]] = {} + + def list_by_project(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]: + items = list(self._items.get(project_id, [])) + if kind is None: + return items + return [item for item in items if item.kind == kind] + + def create(self, library: AssetLibrary) -> AssetLibrary: + items = self._items.setdefault(library.project_id, []) + items.append(library) + return library diff --git a/packages/adapters/in_memory/asset_repository.py b/packages/adapters/in_memory/asset_repository.py new file mode 100644 index 000000000..4b268e58d --- /dev/null +++ b/packages/adapters/in_memory/asset_repository.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from packages.domain import Asset + + +class InMemoryAssetRepository: + def __init__(self): + self._items: dict[str, list[Asset]] = {} + + def list_by_library(self, library_id: str) -> list[Asset]: + return list(self._items.get(library_id, [])) + + def create(self, asset: Asset) -> Asset: + items = self._items.setdefault(asset.library_id, []) + items.append(asset) + return asset diff --git a/packages/adapters/in_memory/ingest_job_repository.py b/packages/adapters/in_memory/ingest_job_repository.py new file mode 100644 index 000000000..13c530bdb --- /dev/null +++ b/packages/adapters/in_memory/ingest_job_repository.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from packages.domain import IngestJob + + +class InMemoryIngestJobRepository: + def __init__(self): + self._items: dict[str, IngestJob] = {} + + def create(self, job: IngestJob) -> IngestJob: + self._items[job.id] = job + return job + + def get(self, job_id: str) -> IngestJob | None: + return self._items.get(job_id) + + def update(self, job: IngestJob) -> IngestJob: + self._items[job.id] = job + return job diff --git a/packages/adapters/in_memory/project_repository.py b/packages/adapters/in_memory/project_repository.py new file mode 100644 index 000000000..848aa122a --- /dev/null +++ b/packages/adapters/in_memory/project_repository.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from packages.domain import Project + + +class InMemoryProjectRepository: + def __init__(self): + self._items: dict[str, list[Project]] = {} + + def list_by_workspace(self, workspace_id: str) -> list[Project]: + return list(self._items.get(workspace_id, [])) + + def create(self, project: Project) -> Project: + items = self._items.setdefault(project.workspace_id, []) + items.append(project) + return project diff --git a/packages/application/__init__.py b/packages/application/__init__.py new file mode 100644 index 000000000..0d8d072d6 --- /dev/null +++ b/packages/application/__init__.py @@ -0,0 +1,20 @@ +"""Application use cases package.""" + +from .asset_libraries import CreateAssetLibraryCommand, CreateAssetLibraryUseCase, ListAssetLibrariesUseCase +from .assets import CreateAssetCommand, CreateAssetUseCase, ListAssetsUseCase +from .ingest_jobs import SubmitIngestJobCommand, SubmitIngestJobUseCase +from .projects import CreateProjectCommand, CreateProjectUseCase, ListProjectsUseCase + +__all__ = [ + "CreateAssetCommand", + "CreateAssetLibraryCommand", + "CreateAssetLibraryUseCase", + "CreateAssetUseCase", + "CreateProjectCommand", + "CreateProjectUseCase", + "ListAssetLibrariesUseCase", + "ListAssetsUseCase", + "ListProjectsUseCase", + "SubmitIngestJobCommand", + "SubmitIngestJobUseCase", +] diff --git a/packages/application/asset_libraries.py b/packages/application/asset_libraries.py new file mode 100644 index 000000000..b18ff3f09 --- /dev/null +++ b/packages/application/asset_libraries.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from packages.domain import AssetLibrary, AssetLibraryKind +from packages.ports.asset_library_repository import AssetLibraryRepository + + +@dataclass(slots=True) +class CreateAssetLibraryCommand: + workspace_id: str + project_id: str + name: str + kind: AssetLibraryKind + + +class ListAssetLibrariesUseCase: + def __init__(self, asset_library_repository: AssetLibraryRepository): + self.asset_library_repository = asset_library_repository + + def execute(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]: + if not project_id.strip(): + raise ValueError("project_id 不能为空") + return self.asset_library_repository.list_by_project(project_id.strip(), kind=kind) + + +class CreateAssetLibraryUseCase: + def __init__(self, asset_library_repository: AssetLibraryRepository): + self.asset_library_repository = asset_library_repository + + def execute(self, command: CreateAssetLibraryCommand) -> AssetLibrary: + library = AssetLibrary.create( + workspace_id=command.workspace_id, + project_id=command.project_id, + name=command.name, + kind=command.kind, + ) + return self.asset_library_repository.create(library) diff --git a/packages/application/assets.py b/packages/application/assets.py new file mode 100644 index 000000000..9aeab342e --- /dev/null +++ b/packages/application/assets.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from packages.domain import Asset +from packages.ports.asset_repository import AssetRepository + + +@dataclass(slots=True) +class CreateAssetCommand: + workspace_id: str + project_id: str + library_id: str + name: str + storage_key: str + mime_type: str + metadata: dict[str, object] | None = None + + +class ListAssetsUseCase: + def __init__(self, asset_repository: AssetRepository): + self.asset_repository = asset_repository + + def execute(self, library_id: str) -> list[Asset]: + if not library_id.strip(): + raise ValueError("library_id 不能为空") + return self.asset_repository.list_by_library(library_id.strip()) + + +class CreateAssetUseCase: + def __init__(self, asset_repository: AssetRepository): + self.asset_repository = asset_repository + + def execute(self, command: CreateAssetCommand) -> Asset: + asset = Asset.create( + workspace_id=command.workspace_id, + project_id=command.project_id, + library_id=command.library_id, + name=command.name, + storage_key=command.storage_key, + mime_type=command.mime_type, + metadata=command.metadata, + ) + return self.asset_repository.create(asset) diff --git a/packages/application/ingest_jobs.py b/packages/application/ingest_jobs.py new file mode 100644 index 000000000..00d5620ba --- /dev/null +++ b/packages/application/ingest_jobs.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from packages.domain import IngestJob +from packages.ports.ingest_job_repository import IngestJobRepository + + +@dataclass(slots=True) +class SubmitIngestJobCommand: + workspace_id: str + project_id: str + library_id: str + storage_key: str + + +class SubmitIngestJobUseCase: + def __init__(self, ingest_job_repository: IngestJobRepository): + self.ingest_job_repository = ingest_job_repository + + def execute(self, command: SubmitIngestJobCommand) -> IngestJob: + job = IngestJob.create( + workspace_id=command.workspace_id, + project_id=command.project_id, + library_id=command.library_id, + storage_key=command.storage_key, + ) + return self.ingest_job_repository.create(job) diff --git a/packages/application/projects.py b/packages/application/projects.py new file mode 100644 index 000000000..16033f9d5 --- /dev/null +++ b/packages/application/projects.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from packages.domain import Project +from packages.ports import ProjectRepository + + +@dataclass(slots=True) +class CreateProjectCommand: + workspace_id: str + name: str + description: str = "" + + +class ListProjectsUseCase: + def __init__(self, project_repository: ProjectRepository): + self.project_repository = project_repository + + def execute(self, workspace_id: str) -> list[Project]: + if not workspace_id.strip(): + raise ValueError("workspace_id 不能为空") + return self.project_repository.list_by_workspace(workspace_id.strip()) + + +class CreateProjectUseCase: + def __init__(self, project_repository: ProjectRepository): + self.project_repository = project_repository + + def execute(self, command: CreateProjectCommand) -> Project: + project = Project.create( + workspace_id=command.workspace_id, + name=command.name, + description=command.description, + ) + return self.project_repository.create(project) diff --git a/packages/domain/__init__.py b/packages/domain/__init__.py new file mode 100644 index 000000000..bdb888eb8 --- /dev/null +++ b/packages/domain/__init__.py @@ -0,0 +1,14 @@ +"""Domain package for core business entities and rules.""" + +from .entities import Asset, AssetLibrary, AssetLibraryKind, IngestJob, IngestJobStatus, Project, User, Workspace + +__all__ = [ + "Asset", + "AssetLibrary", + "AssetLibraryKind", + "IngestJob", + "IngestJobStatus", + "Project", + "User", + "Workspace", +] diff --git a/packages/domain/entities.py b/packages/domain/entities.py new file mode 100644 index 000000000..e55b044de --- /dev/null +++ b/packages/domain/entities.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import StrEnum +from typing import Any +from uuid import uuid4 + + +class AssetLibraryKind(StrEnum): + VIDEO = "video" + VOICE = "voice" + + +class IngestJobStatus(StrEnum): + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + + +@dataclass(slots=True) +class User: + id: str + email: str + display_name: str + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class Workspace: + id: str + name: str + owner_user_id: str + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class Project: + id: str + workspace_id: str + name: str + description: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @classmethod + def create(cls, workspace_id: str, name: str, description: str = "") -> "Project": + clean_name = name.strip() + if not clean_name: + raise ValueError("项目名称不能为空") + return cls( + id=uuid4().hex, + workspace_id=workspace_id, + name=clean_name, + description=description.strip(), + ) + + +@dataclass(slots=True) +class AssetLibrary: + id: str + workspace_id: str + project_id: str + name: str + kind: AssetLibraryKind + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @classmethod + def create( + cls, + workspace_id: str, + project_id: str, + name: str, + kind: AssetLibraryKind, + ) -> "AssetLibrary": + clean_name = name.strip() + if not clean_name: + raise ValueError("素材库名称不能为空") + return cls( + id=uuid4().hex, + workspace_id=workspace_id, + project_id=project_id, + name=clean_name, + kind=kind, + ) + + +@dataclass(slots=True) +class Asset: + id: str + workspace_id: str + project_id: str + library_id: str + name: str + storage_key: str + mime_type: str + metadata: dict[str, Any] = field(default_factory=dict) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @classmethod + def create( + cls, + workspace_id: str, + project_id: str, + library_id: str, + name: str, + storage_key: str, + mime_type: str, + metadata: dict[str, Any] | None = None, + ) -> "Asset": + clean_name = name.strip() + if not clean_name: + raise ValueError("素材名称不能为空") + if not storage_key.strip(): + raise ValueError("storage_key 不能为空") + if not mime_type.strip(): + raise ValueError("mime_type 不能为空") + return cls( + id=uuid4().hex, + workspace_id=workspace_id, + project_id=project_id, + library_id=library_id, + name=clean_name, + storage_key=storage_key.strip(), + mime_type=mime_type.strip(), + metadata=metadata or {}, + ) + + +@dataclass(slots=True) +class IngestJob: + id: str + workspace_id: str + project_id: str + library_id: str + storage_key: str + status: IngestJobStatus = IngestJobStatus.PENDING + error_message: str = "" + result_asset_id: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @classmethod + def create( + cls, + workspace_id: str, + project_id: str, + library_id: str, + storage_key: str, + ) -> "IngestJob": + if not workspace_id.strip(): + raise ValueError("workspace_id 不能为空") + if not project_id.strip(): + raise ValueError("project_id 不能为空") + if not library_id.strip(): + raise ValueError("library_id 不能为空") + if not storage_key.strip(): + raise ValueError("storage_key 不能为空") + return cls( + id=uuid4().hex, + workspace_id=workspace_id.strip(), + project_id=project_id.strip(), + library_id=library_id.strip(), + storage_key=storage_key.strip(), + ) diff --git a/packages/ports/__init__.py b/packages/ports/__init__.py new file mode 100644 index 000000000..6a6259f64 --- /dev/null +++ b/packages/ports/__init__.py @@ -0,0 +1,13 @@ +"""Ports package for infrastructure-agnostic interfaces.""" + +from .asset_library_repository import AssetLibraryRepository +from .asset_repository import AssetRepository +from .ingest_job_repository import IngestJobRepository +from .project_repository import ProjectRepository + +__all__ = [ + "AssetLibraryRepository", + "AssetRepository", + "IngestJobRepository", + "ProjectRepository", +] diff --git a/packages/ports/asset_library_repository.py b/packages/ports/asset_library_repository.py new file mode 100644 index 000000000..bb63dedd5 --- /dev/null +++ b/packages/ports/asset_library_repository.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Protocol + +from packages.domain import AssetLibrary, AssetLibraryKind + + +class AssetLibraryRepository(Protocol): + def list_by_project(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]: + """List asset libraries for a project.""" + + def create(self, library: AssetLibrary) -> AssetLibrary: + """Persist an asset library and return it.""" diff --git a/packages/ports/asset_repository.py b/packages/ports/asset_repository.py new file mode 100644 index 000000000..88480d648 --- /dev/null +++ b/packages/ports/asset_repository.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Protocol + +from packages.domain import Asset + + +class AssetRepository(Protocol): + def list_by_library(self, library_id: str) -> list[Asset]: + """List assets for a library.""" + + def create(self, asset: Asset) -> Asset: + """Persist an asset and return it.""" diff --git a/packages/ports/ingest_job_repository.py b/packages/ports/ingest_job_repository.py new file mode 100644 index 000000000..d9184fe79 --- /dev/null +++ b/packages/ports/ingest_job_repository.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Protocol + +from packages.domain import IngestJob + + +class IngestJobRepository(Protocol): + def create(self, job: IngestJob) -> IngestJob: + """Persist an ingest job and return it.""" + + def get(self, job_id: str) -> IngestJob | None: + """Retrieve an ingest job by ID.""" + + def update(self, job: IngestJob) -> IngestJob: + """Update an ingest job and return it.""" diff --git a/packages/ports/project_repository.py b/packages/ports/project_repository.py new file mode 100644 index 000000000..eae76ed99 --- /dev/null +++ b/packages/ports/project_repository.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Protocol + +from packages.domain import Project + + +class ProjectRepository(Protocol): + def list_by_workspace(self, workspace_id: str) -> list[Project]: + """List projects for a workspace.""" + + def create(self, project: Project) -> Project: + """Persist a project and return it.""" diff --git a/packages/shared/__init__.py b/packages/shared/__init__.py new file mode 100644 index 000000000..7dbba873f --- /dev/null +++ b/packages/shared/__init__.py @@ -0,0 +1 @@ +"""Shared package for cross-cutting, non-business-specific utilities.""" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..c7b23ecb1 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..49544374c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# Python runtime dependencies for API and worker will be managed here. +# Initial scaffold only; versions to be pinned after stack confirmation. +fastapi +uvicorn +pydantic +celery +redis +sqlalchemy +alembic +psycopg[binary] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..f96b4d863 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 000000000..fd0c35547 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1 @@ +# End-to-end tests placeholder diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 000000000..3ade6390a --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1 @@ +# Integration tests placeholder diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py new file mode 100644 index 000000000..7ca5e5862 --- /dev/null +++ b/tests/integration/test_projects.py @@ -0,0 +1,102 @@ +from packages.application import ( + CreateAssetCommand, + CreateAssetLibraryCommand, + CreateAssetLibraryUseCase, + CreateAssetUseCase, + CreateProjectCommand, + CreateProjectUseCase, + ListAssetLibrariesUseCase, + ListAssetsUseCase, + ListProjectsUseCase, + SubmitIngestJobCommand, + SubmitIngestJobUseCase, +) +from packages.adapters.in_memory import ( + InMemoryAssetLibraryRepository, + InMemoryAssetRepository, + InMemoryIngestJobRepository, + InMemoryProjectRepository, +) +from packages.domain import AssetLibraryKind, IngestJobStatus + + +def test_create_and_list_projects(): + repository = InMemoryProjectRepository() + create_use_case = CreateProjectUseCase(repository) + list_use_case = ListProjectsUseCase(repository) + + project = create_use_case.execute(CreateProjectCommand(workspace_id="ws-1", name=" Demo Project ")) + assert project.name == "Demo Project" + assert project.workspace_id == "ws-1" + + items = list_use_case.execute("ws-1") + assert len(items) == 1 + assert items[0].id == project.id + + +def test_create_and_list_asset_libraries(): + repository = InMemoryAssetLibraryRepository() + create_use_case = CreateAssetLibraryUseCase(repository) + list_use_case = ListAssetLibrariesUseCase(repository) + + library = create_use_case.execute( + CreateAssetLibraryCommand( + workspace_id="ws-1", + project_id="proj-1", + name=" 素材库 A ", + kind=AssetLibraryKind.VIDEO, + ) + ) + assert library.name == "素材库 A" + assert library.kind == AssetLibraryKind.VIDEO + + items = list_use_case.execute("proj-1", kind=AssetLibraryKind.VIDEO) + assert len(items) == 1 + assert items[0].id == library.id + + +def test_create_and_list_assets(): + repository = InMemoryAssetRepository() + create_use_case = CreateAssetUseCase(repository) + list_use_case = ListAssetsUseCase(repository) + + asset = create_use_case.execute( + CreateAssetCommand( + workspace_id="ws-1", + project_id="proj-1", + library_id="lib-1", + name=" demo.mp4 ", + storage_key="uploads/demo.mp4", + mime_type="video/mp4", + metadata={"duration": 12.5}, + ) + ) + assert asset.name == "demo.mp4" + assert asset.storage_key == "uploads/demo.mp4" + assert asset.metadata["duration"] == 12.5 + + items = list_use_case.execute("lib-1") + assert len(items) == 1 + assert items[0].id == asset.id + + +def test_submit_ingest_job(): + repository = InMemoryIngestJobRepository() + use_case = SubmitIngestJobUseCase(repository) + + job = use_case.execute( + SubmitIngestJobCommand( + workspace_id="ws-1", + project_id="proj-1", + library_id="lib-1", + storage_key="uploads/raw-video.mp4", + ) + ) + assert job.workspace_id == "ws-1" + assert job.storage_key == "uploads/raw-video.mp4" + assert job.status == IngestJobStatus.PENDING + assert job.result_asset_id == "" + + retrieved = repository.get(job.id) + assert retrieved is not None + assert retrieved.id == job.id