diff --git a/alembic/versions/005_add_generated_video_management_fields.py b/alembic/versions/005_add_generated_video_management_fields.py index a3ba21d3f..86d3a3b81 100644 --- a/alembic/versions/005_add_generated_video_management_fields.py +++ b/alembic/versions/005_add_generated_video_management_fields.py @@ -17,8 +17,13 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.add_column("generated_videos", sa.Column("status", sa.String(length=20), nullable=False, server_default="completed")) - op.add_column("generated_videos", sa.Column("review_status", sa.String(length=20), nullable=False, server_default="pending_review")) + op.add_column( + "generated_videos", sa.Column("status", sa.String(length=20), nullable=False, server_default="completed") + ) + op.add_column( + "generated_videos", + sa.Column("review_status", sa.String(length=20), nullable=False, server_default="pending_review"), + ) op.add_column("generated_videos", sa.Column("generation_params", sa.Text(), nullable=False, server_default="{}")) op.add_column("generated_videos", sa.Column("updated_at", sa.DateTime(), nullable=True)) op.create_index(op.f("ix_generated_videos_status"), "generated_videos", ["status"], unique=False) diff --git a/alembic/versions/006_add_edit_plan_tables.py b/alembic/versions/006_add_edit_plan_tables.py index a891813bb..6a69919b7 100644 --- a/alembic/versions/006_add_edit_plan_tables.py +++ b/alembic/versions/006_add_edit_plan_tables.py @@ -1,7 +1,6 @@ from alembic import op import sqlalchemy as sa - revision = "006" down_revision = "005" branch_labels = None diff --git a/apps/api/app/api/routes/asset_diagnosis.py b/apps/api/app/api/routes/asset_diagnosis.py index 4fa210232..1b3cdb2e8 100644 --- a/apps/api/app/api/routes/asset_diagnosis.py +++ b/apps/api/app/api/routes/asset_diagnosis.py @@ -42,10 +42,15 @@ def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> video_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VIDEO] image_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.IMAGE] voice_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VOICE] - problem_assets = [asset for asset in assets if asset.status in {AssetStatus.ERROR, AssetStatus.UPLOADING, AssetStatus.PROCESSING}] - unclassified_assets = [asset for asset in ready_assets if asset.classification_status.value in {"pending", "failed"}] + problem_assets = [ + asset for asset in assets if asset.status in {AssetStatus.ERROR, AssetStatus.UPLOADING, AssetStatus.PROCESSING} + ] + unclassified_assets = [ + asset for asset in ready_assets if asset.classification_status.value in {"pending", "failed"} + ] risky_assets = [ - asset for asset in ready_assets + asset + for asset in ready_assets if (asset.quality_score is not None and asset.quality_score < 60) or asset.metadata.get("review_status") == "rejected" or asset.status == AssetStatus.ERROR @@ -54,7 +59,9 @@ def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> unused_assets = [asset for asset in ready_assets if int(asset.metadata.get("generation_use_count") or 0) == 0] pending_review_assets = [asset for asset in ready_assets if asset.metadata.get("review_status") == "pending_review"] total_duration = round(sum(float(asset.duration or 0) for asset in video_assets), 2) - estimated_video_count = max(0, min(len(video_assets), int(total_duration // 5) if total_duration else len(video_assets))) + estimated_video_count = max( + 0, min(len(video_assets), int(total_duration // 5) if total_duration else len(video_assets)) + ) score = 20 if video_assets: @@ -128,15 +135,43 @@ def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> ) smart_views = [ - AssetSmartViewItem(key="recommended", label="推荐素材", count=len(video_assets), description="已导入完成、可参与生成的视频素材"), - AssetSmartViewItem(key="needs_attention", label="慎用素材", count=len(problem_assets) + len(risky_assets), description="导入未完成、失败或质量分偏低的素材"), - AssetSmartViewItem(key="high_risk", label="高风险素材", count=len(risky_assets), description="质量分偏低或复核拒绝的素材"), - AssetSmartViewItem(key="unclassified", label="未分类素材", count=len(unclassified_assets), description="尚未完成分类或分类失败的 ready 素材"), - AssetSmartViewItem(key="recent", label="最近上传", count=min(len(assets), 10), description="最近进入素材库的素材,可用于快速复核"), - AssetSmartViewItem(key="unused", label="未使用素材", count=len(unused_assets), description="尚未参与生成的 ready 素材"), + AssetSmartViewItem( + key="recommended", label="推荐素材", count=len(video_assets), description="已导入完成、可参与生成的视频素材" + ), + AssetSmartViewItem( + key="needs_attention", + label="慎用素材", + count=len(problem_assets) + len(risky_assets), + description="导入未完成、失败或质量分偏低的素材", + ), + AssetSmartViewItem( + key="high_risk", label="高风险素材", count=len(risky_assets), description="质量分偏低或复核拒绝的素材" + ), + AssetSmartViewItem( + key="unclassified", + label="未分类素材", + count=len(unclassified_assets), + description="尚未完成分类或分类失败的 ready 素材", + ), + AssetSmartViewItem( + key="recent", + label="最近上传", + count=min(len(assets), 10), + description="最近进入素材库的素材,可用于快速复核", + ), + AssetSmartViewItem( + key="unused", label="未使用素材", count=len(unused_assets), description="尚未参与生成的 ready 素材" + ), AssetSmartViewItem(key="used", label="已使用素材", count=len(used_assets), description="已经参与过生成的素材"), - AssetSmartViewItem(key="pending_review", label="待复核素材", count=len(pending_review_assets), description="生成后待人工复核的素材"), - AssetSmartViewItem(key="voice", label="配音素材", count=len(voice_assets), description="可用于后续配音/旁白工作流的素材"), + AssetSmartViewItem( + key="pending_review", + label="待复核素材", + count=len(pending_review_assets), + description="生成后待人工复核的素材", + ), + AssetSmartViewItem( + key="voice", label="配音素材", count=len(voice_assets), description="可用于后续配音/旁白工作流的素材" + ), ] return ProjectAssetDiagnosisResponse( @@ -179,4 +214,3 @@ def get_project_asset_diagnosis( assets.extend(asset_repository.list_by_library(library.id)) return _build_diagnosis(project.workspace_id, project_id, assets) - diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index c36503454..9488f5125 100644 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -3,7 +3,12 @@ from uuid import uuid4 from app.api.routes.permissions import require_workspace_member from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_asset_repository, get_db_session, get_project_repository, get_workspace_member_repository +from app.dependencies import ( + get_asset_repository, + get_db_session, + get_project_repository, + get_workspace_member_repository, +) from app.schemas.edit_plan import CreateEditPlanRequest, EditPlanClipResponse, EditPlanResponse, EditTemplateResponse from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session @@ -24,10 +29,15 @@ def _ensure_project(project_id: str, workspace_id: str, user: AuthenticatedUser, def _default_template(session: Session, workspace_id: str, project_id: str, user_id: str) -> EditTemplateModel: - template = session.query(EditTemplateModel).filter( - EditTemplateModel.project_id == project_id, - EditTemplateModel.is_active.is_(True), - ).order_by(EditTemplateModel.created_at.asc()).first() + template = ( + session.query(EditTemplateModel) + .filter( + EditTemplateModel.project_id == project_id, + EditTemplateModel.is_active.is_(True), + ) + .order_by(EditTemplateModel.created_at.asc()) + .first() + ) if template is not None: return template template = EditTemplateModel( @@ -59,7 +69,9 @@ def _to_template_response(template: EditTemplateModel) -> EditTemplateResponse: ) -def _to_plan_response(plan: EditPlanModel, clips: list[EditPlanClipModel], asset_names: dict[str, str]) -> EditPlanResponse: +def _to_plan_response( + plan: EditPlanModel, clips: list[EditPlanClipModel], asset_names: dict[str, str] +) -> EditPlanResponse: return EditPlanResponse( id=plan.id, workspace_id=plan.workspace_id, @@ -69,15 +81,18 @@ def _to_plan_response(plan: EditPlanModel, clips: list[EditPlanClipModel], asset title_id=plan.title_id, status=plan.status, summary=plan.summary, - clips=[EditPlanClipResponse( - id=clip.id, - asset_id=clip.asset_id, - asset_name=asset_names.get(clip.asset_id, clip.asset_id), - sequence=clip.sequence, - start_time=float(clip.start_time or 0), - duration=float(clip.duration or 0), - reason=clip.reason, - ) for clip in clips], + clips=[ + EditPlanClipResponse( + id=clip.id, + asset_id=clip.asset_id, + asset_name=asset_names.get(clip.asset_id, clip.asset_id), + sequence=clip.sequence, + start_time=float(clip.start_time or 0), + duration=float(clip.duration or 0), + reason=clip.reason, + ) + for clip in clips + ], created_at=plan.created_at, updated_at=plan.updated_at, ) @@ -94,7 +109,11 @@ def list_edit_templates( ) -> list[EditTemplateResponse]: _ensure_project(project_id, workspace_id, authenticated_user, project_repository, workspace_member_repository) template = _default_template(session, workspace_id, project_id, authenticated_user.user.id) - templates = session.query(EditTemplateModel).filter(EditTemplateModel.project_id == project_id, EditTemplateModel.is_active.is_(True)).all() + templates = ( + session.query(EditTemplateModel) + .filter(EditTemplateModel.project_id == project_id, EditTemplateModel.is_active.is_(True)) + .all() + ) return [_to_template_response(item) for item in templates or [template]] @@ -108,14 +127,26 @@ def create_edit_plan( workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), session: Session = Depends(get_db_session), ) -> EditPlanResponse: - _ensure_project(project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository) - template = session.query(EditTemplateModel).filter(EditTemplateModel.id == request.template_id).first() if request.template_id else None + _ensure_project( + project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository + ) + template = ( + session.query(EditTemplateModel).filter(EditTemplateModel.id == request.template_id).first() + if request.template_id + else None + ) if template is None: template = _default_template(session, request.workspace_id, project_id, authenticated_user.user.id) - assets = [asset for asset in asset_repository.list_by_library(request.asset_library_id) if asset.status == AssetStatus.READY and asset.mime_type.startswith("video/")] + assets = [ + asset + for asset in asset_repository.list_by_library(request.asset_library_id) + if asset.status == AssetStatus.READY and asset.mime_type.startswith("video/") + ] if not assets: raise HTTPException(status_code=422, detail="素材库暂无可用于剪辑计划的视频素材") - selected = sorted(assets, key=lambda asset: (-(asset.quality_score or 0), asset.created_at))[: max(1, int(template.clip_count or 3))] + selected = sorted(assets, key=lambda asset: (-(asset.quality_score or 0), asset.created_at))[ + : max(1, int(template.clip_count or 3)) + ] plan = EditPlanModel( id=uuid4().hex, workspace_id=request.workspace_id, @@ -157,10 +188,17 @@ def get_edit_plan( workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), session: Session = Depends(get_db_session), ) -> EditPlanResponse: - plan = session.query(EditPlanModel).filter(EditPlanModel.id == plan_id, EditPlanModel.project_id == project_id).first() + plan = ( + session.query(EditPlanModel).filter(EditPlanModel.id == plan_id, EditPlanModel.project_id == project_id).first() + ) if plan is None: raise HTTPException(status_code=404, detail="Edit plan not found") _ensure_project(project_id, plan.workspace_id, authenticated_user, project_repository, workspace_member_repository) - clips = session.query(EditPlanClipModel).filter(EditPlanClipModel.edit_plan_id == plan.id).order_by(EditPlanClipModel.sequence.asc()).all() + clips = ( + session.query(EditPlanClipModel) + .filter(EditPlanClipModel.edit_plan_id == plan.id) + .order_by(EditPlanClipModel.sequence.asc()) + .all() + ) assets = asset_repository.list_by_library(plan.asset_library_id) return _to_plan_response(plan, clips, {asset.id: asset.name for asset in assets}) diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index 9781d0468..3d9c0e454 100644 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -68,8 +68,7 @@ def _to_generated_video_response(item) -> GeneratedVideoResponse: def _ensure_library_has_ready_video_assets(assets) -> None: ready_video_assets = [ - asset for asset in assets - if asset.status.value == "ready" and asset.mime_type.startswith("video") + asset for asset in assets if asset.status.value == "ready" and asset.mime_type.startswith("video") ] if not ready_video_assets: raise HTTPException( @@ -82,7 +81,14 @@ def _select_title_id(project_title_repository: Any, project_id: str) -> str: active_titles = project_title_repository.list_by_project(project_id, active_only=True) if not active_titles: return "" - selected = sorted(active_titles, key=lambda title: (0 if getattr(title, "favorite", False) else 1, int(title.usage_count or 0), title.created_at))[0] + selected = sorted( + active_titles, + key=lambda title: ( + 0 if getattr(title, "favorite", False) else 1, + int(title.usage_count or 0), + title.created_at, + ), + )[0] return selected.id diff --git a/apps/api/app/api/routes/project_titles.py b/apps/api/app/api/routes/project_titles.py index 4fefc8897..0aa4e248c 100644 --- a/apps/api/app/api/routes/project_titles.py +++ b/apps/api/app/api/routes/project_titles.py @@ -53,7 +53,9 @@ def list_project_titles( ) -> ListProjectTitlesResponse: project = _get_project_or_404(project_id, project_repository) require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository) - return ListProjectTitlesResponse(items=[_to_response(item) for item in title_repository.list_by_project(project_id, active_only)]) + return ListProjectTitlesResponse( + items=[_to_response(item) for item in title_repository.list_by_project(project_id, active_only)] + ) @router.post("/projects/{project_id}/titles", response_model=ProjectTitleResponse) diff --git a/apps/api/app/api/routes/task_center.py b/apps/api/app/api/routes/task_center.py index dacc79ae3..95d7b5e41 100644 --- a/apps/api/app/api/routes/task_center.py +++ b/apps/api/app/api/routes/task_center.py @@ -12,7 +12,12 @@ from app.schemas.task_center import ListProjectTasksResponse, ProjectTaskRespons from app.core.celery_app import celery_app from fastapi import APIRouter, Depends, HTTPException -from packages.application import CreateGenerationTaskCommand, CreateGenerationTaskUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase +from packages.application import ( + CreateGenerationTaskCommand, + CreateGenerationTaskUseCase, + SubmitIngestJobCommand, + SubmitIngestJobUseCase, +) from packages.ports.workspace_member_repository import WorkspaceMemberRepository router = APIRouter() @@ -72,37 +77,41 @@ def list_project_tasks( items: list[ProjectTaskResponse] = [] for job in ingest_job_repository.list_by_project(project_id): - items.append(ProjectTaskResponse( - id=f"ingest:{job.id}", - task_type="ingest", - workspace_id=job.workspace_id, - project_id=job.project_id, - status=job.status.value, - progress=100.0 if job.status.value == "completed" else 0.0, - current_step=_ingest_step(job), - error_message=job.error_message, - user_message=_humanize_task_error(job.error_message), - retryable=job.status.value == "failed", - source_id=job.id, - created_at=job.created_at, - updated_at=job.updated_at, - )) + items.append( + ProjectTaskResponse( + id=f"ingest:{job.id}", + task_type="ingest", + workspace_id=job.workspace_id, + project_id=job.project_id, + status=job.status.value, + progress=100.0 if job.status.value == "completed" else 0.0, + current_step=_ingest_step(job), + error_message=job.error_message, + user_message=_humanize_task_error(job.error_message), + retryable=job.status.value == "failed", + source_id=job.id, + created_at=job.created_at, + updated_at=job.updated_at, + ) + ) for task in generation_task_repository.list_by_project(project_id): - items.append(ProjectTaskResponse( - id=f"generation:{task.id}", - task_type="generation", - workspace_id=task.workspace_id, - project_id=task.project_id, - status=task.status.value, - progress=task.progress, - current_step=_generation_step(task), - error_message=task.error_message, - user_message=_humanize_task_error(task.error_message), - retryable=task.status.value == "failed", - source_id=task.id, - created_at=task.created_at, - updated_at=task.completed_at or task.started_at or task.created_at, - )) + items.append( + ProjectTaskResponse( + id=f"generation:{task.id}", + task_type="generation", + workspace_id=task.workspace_id, + project_id=task.project_id, + status=task.status.value, + progress=task.progress, + current_step=_generation_step(task), + error_message=task.error_message, + user_message=_humanize_task_error(task.error_message), + retryable=task.status.value == "failed", + source_id=task.id, + created_at=task.created_at, + updated_at=task.completed_at or task.started_at or task.created_at, + ) + ) items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True) return ListProjectTasksResponse(items=items) @@ -124,20 +133,28 @@ def retry_project_task( if task.status.value != "failed": raise HTTPException(status_code=409, detail="Only failed tasks can be retried") use_case = CreateGenerationTaskUseCase(generation_task_repository) - retried = use_case.execute(CreateGenerationTaskCommand( - workspace_id=task.workspace_id, - project_id=task.project_id, - asset_library_id=task.asset_library_id, - strategy_id=task.strategy_id, - voice_library_id=task.voice_library_id, - edit_plan_id=task.edit_plan_id, - created_by_user_id=authenticated_user.user.id, - )) + retried = use_case.execute( + CreateGenerationTaskCommand( + workspace_id=task.workspace_id, + project_id=task.project_id, + asset_library_id=task.asset_library_id, + strategy_id=task.strategy_id, + voice_library_id=task.voice_library_id, + edit_plan_id=task.edit_plan_id, + created_by_user_id=authenticated_user.user.id, + ) + ) celery_app.send_task("worker.generate_video", args=[retried.id]) return ProjectTaskResponse( - id=f"generation:{retried.id}", task_type="generation", workspace_id=retried.workspace_id, - project_id=retried.project_id, status=retried.status.value, progress=retried.progress, - current_step=_generation_step(retried), source_id=retried.id, created_at=retried.created_at, + id=f"generation:{retried.id}", + task_type="generation", + workspace_id=retried.workspace_id, + project_id=retried.project_id, + status=retried.status.value, + progress=retried.progress, + current_step=_generation_step(retried), + source_id=retried.id, + created_at=retried.created_at, updated_at=retried.created_at, ) if task_type == "ingest": @@ -148,18 +165,25 @@ def retry_project_task( if job.status.value != "failed": raise HTTPException(status_code=409, detail="Only failed tasks can be retried") use_case = SubmitIngestJobUseCase(ingest_job_repository) - retried = use_case.execute(SubmitIngestJobCommand( - workspace_id=job.workspace_id, - project_id=job.project_id, - library_id=job.library_id, - storage_key=job.storage_key, - )) + retried = use_case.execute( + SubmitIngestJobCommand( + workspace_id=job.workspace_id, + project_id=job.project_id, + library_id=job.library_id, + storage_key=job.storage_key, + ) + ) celery_app.send_task("worker.ingest_asset", args=[retried.id]) return ProjectTaskResponse( - id=f"ingest:{retried.id}", task_type="ingest", workspace_id=retried.workspace_id, - project_id=retried.project_id, status=retried.status.value, progress=0, - current_step=_ingest_step(retried), source_id=retried.id, created_at=retried.created_at, + id=f"ingest:{retried.id}", + task_type="ingest", + workspace_id=retried.workspace_id, + project_id=retried.project_id, + status=retried.status.value, + progress=0, + current_step=_ingest_step(retried), + source_id=retried.id, + created_at=retried.created_at, updated_at=retried.updated_at, ) raise HTTPException(status_code=400, detail="Unsupported task type") - diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index a41f3cd37..791058811 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -1,5 +1,6 @@ import os import shutil + # ffmpeg/ffprobe are invoked with fixed argument lists and shell=False. import subprocess # nosec B404 import tempfile diff --git a/scripts/create_tracker_db.py b/scripts/create_tracker_db.py index 6364185a8..310dbfdd0 100644 --- a/scripts/create_tracker_db.py +++ b/scripts/create_tracker_db.py @@ -1,13 +1,15 @@ """ 创建推进器数据库表结构 """ + import sqlite3 + def create_tables(): """创建数据库表""" conn = sqlite3.connect("F:/openclaw-saas/tracker.db") cursor = conn.cursor() - + # 创建 tasks 表 cursor.execute(""" CREATE TABLE IF NOT EXISTS tasks ( @@ -27,9 +29,9 @@ def create_tables(): tags TEXT ) """) - + print("[OK] tasks 表创建成功") - + # 创建 milestones 表 cursor.execute(""" CREATE TABLE IF NOT EXISTS milestones ( @@ -42,9 +44,9 @@ def create_tables(): description TEXT ) """) - + print("[OK] milestones 表创建成功") - + # 创建 logs 表 cursor.execute(""" CREATE TABLE IF NOT EXISTS logs ( @@ -55,12 +57,12 @@ def create_tables(): FOREIGN KEY (task_id) REFERENCES tasks (id) ) """) - + print("[OK] logs 表创建成功") - + conn.commit() conn.close() - + print("\n[SUCCESS] 数据库表结构创建完成!") diff --git a/scripts/init_phase4_tasks.py b/scripts/init_phase4_tasks.py index cd504c278..8947c2244 100644 --- a/scripts/init_phase4_tasks.py +++ b/scripts/init_phase4_tasks.py @@ -4,9 +4,11 @@ Phase 4 任务数据初始化脚本 将认证、权限、订阅计费的所有任务录入推进器 """ + import sys import io -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") import requests import json @@ -16,6 +18,7 @@ API_BASE = "http://47.98.113.167:8089/api/v1" PROJECT_ID = "00000000-0000-0000-0000-000000000001" WORKSPACE_ID = "00000000-0000-0000-0000-000000000001" + def create_milestone(name, target_date, description=""): """创建里程碑""" payload = { @@ -23,7 +26,7 @@ def create_milestone(name, target_date, description=""): "target_date": target_date, "project_id": PROJECT_ID, "workspace_id": WORKSPACE_ID, - "description": description + "description": description, } resp = requests.post(f"{API_BASE}/project-management/milestones", json=payload) if resp.status_code == 200: @@ -33,6 +36,7 @@ def create_milestone(name, target_date, description=""): print(f"[FAIL] 里程碑: {name} - {resp.text}") return None + def create_task(name, description, priority="medium"): """创建任务""" payload = { @@ -50,151 +54,137 @@ def create_task(name, description, priority="medium"): print(f" [FAIL] {name}") return None + def main(): print("=" * 60) print("Phase 4: SAAS 产品化 - 任务初始化") print("=" * 60) - + # ========== Milestone 1: 认证与账号体系 ========== print("\n[1/4] 创建里程碑:认证与账号体系...") - create_milestone( - "认证与账号体系", - "2026-06-23", - "JWT 登录、注册、密码管理、Session 管理" - ) - + create_milestone("认证与账号体系", "2026-06-23", "JWT 登录、注册、密码管理、Session 管理") + print("\n 录入任务...") - + # Day 1-2: 基础设施 create_task("JWT 工具类实现", "sign/verify/refresh Token 功能", "high") create_task("bcrypt 密码哈希工具", "密码加密存储", "high") create_task("Redis Session 存储", "refresh_token 存储和管理", "high") create_task("邮件服务封装", "SMTP + 邮件模板(欢迎/验证/重置)", "medium") create_task("User 实体扩展", "新增 password_hash/email_verified 等字段", "high") - + # Day 3-4: 核心认证 create_task("注册 API", "邮箱 + 密码注册,发送验证邮件", "urgent") create_task("登录 API", "JWT 签发 access_token + refresh_token", "urgent") create_task("登出 API", "撤销 refresh_token", "high") create_task("刷新 Token API", "用 refresh_token 换取新 access_token", "high") create_task("密码重置流程", "忘记密码 + 邮件重置链接", "medium") - + # Day 5-6: Session 管理 create_task("Session 实体与 Repository", "存储设备信息、IP、过期时间", "medium") create_task("活跃 Session 列表 API", "查看所有登录设备", "low") create_task("强制登出所有设备", "撤销所有 refresh_token", "medium") create_task("设备信息解析", "解析 User-Agent 识别设备类型", "low") - + # Day 7: 测试与文档 create_task("认证集成测试", "注册/登录/登出/刷新完整流程", "high") create_task("认证安全测试", "密码强度/Token 伪造/暴力破解防护", "urgent") create_task("认证 API 文档更新", "OpenAPI 规范更新", "low") - + # ========== Milestone 2: 多租户权限体系 ========== print("\n[2/4] 创建里程碑:多租户权限体系...") - create_milestone( - "多租户权限体系", - "2026-06-30", - "4 种角色、成员管理、邀请系统、数据隔离" - ) - + create_milestone("多租户权限体系", "2026-06-30", "4 种角色、成员管理、邀请系统、数据隔离") + print("\n 录入任务...") - + # Day 1-2: 权限基础 create_task("WorkspaceMembership 实体", "成员关系、角色存储", "high") create_task("WorkspaceRole 枚举", "Owner/Admin/Member/Viewer 权限定义", "high") create_task("权限检查中间件", "@require_permission 装饰器", "urgent") create_task("数据隔离过滤器", "所有查询自动加 workspace_id", "urgent") - + # Day 3-4: 成员管理 create_task("邀请成员 API", "发送邀请邮件 + 生成邀请令牌", "high") create_task("接受/拒绝邀请 API", "处理邀请状态", "high") create_task("移除成员 API", "删除 WorkspaceMembership", "medium") create_task("修改成员角色 API", "Owner/Admin 可修改其他人角色", "medium") create_task("转让 Workspace 所有权", "Owner 转让给其他成员", "low") - + # Day 5-6: 权限验证 create_task("为所有现有 API 加权限检查", "项目/任务/素材 API 权限保护", "urgent") create_task("跨 Workspace 访问防护测试", "确保数据隔离无漏洞", "urgent") create_task("权限矩阵验证", "测试各角色权限边界", "high") - + # Day 7: 测试与文档 create_task("权限系统集成测试", "各角色操作权限完整测试", "high") create_task("数据隔离安全测试", "跨 Workspace 攻击测试", "urgent") create_task("权限 API 文档更新", "成员管理 API 文档", "low") - + # ========== Milestone 3: 订阅与计费 ========== print("\n[3/4] 创建里程碑:订阅与计费...") - create_milestone( - "订阅与计费体系", - "2026-07-07", - "3 种套餐、支付宝/微信支付、账单管理" - ) - + create_milestone("订阅与计费体系", "2026-07-07", "3 种套餐、支付宝/微信支付、账单管理") + print("\n 录入任务...") - + # Day 1-2: 订阅基础 create_task("Subscription 实体与 Repository", "订阅状态、套餐、到期时间", "high") create_task("SubscriptionPlan 枚举", "free/pro/enterprise 套餐定义", "high") create_task("配额检查工具", "检查 Workspace/项目/存储限制", "high") create_task("套餐限制中间件", "创建资源前检查配额", "urgent") - + # Day 3-4: 支付集成 create_task("支付宝 SDK 集成", "生成支付二维码", "high") create_task("微信支付 SDK 集成", "生成支付二维码", "high") create_task("创建支付订单 API", "用户选择套餐 → 生成订单", "high") create_task("支付回调处理", "验证签名 + 更新订单状态 + 激活订阅", "urgent") - + # Day 5-6: 账单管理 create_task("Invoice 实体与 Repository", "账单记录存储", "medium") create_task("生成账单 PDF", "使用模板生成 PDF 发票", "low") create_task("账单列表/下载 API", "用户查看历史账单", "medium") create_task("订阅历史记录", "订阅变更历史追踪", "low") - + # Day 7: 测试与文档 create_task("支付流程端到端测试", "沙箱环境完整支付流程", "high") create_task("配额检查测试", "超出限制时正确拦截", "high") create_task("计费 API 文档更新", "订阅/支付/账单 API 文档", "low") - + # ========== Milestone 4: 前端集成与上线 ========== print("\n[4/4] 创建里程碑:前端集成与上线...") - create_milestone( - "前端集成与上线", - "2026-07-14", - "认证 UI、权限 UI、订阅 UI、端到端测试、生产部署" - ) - + create_milestone("前端集成与上线", "2026-07-14", "认证 UI、权限 UI、订阅 UI、端到端测试、生产部署") + print("\n 录入任务...") - + # Day 1-2: 认证 UI create_task("登录页面", "邮箱/密码登录表单 + 记住我", "high") create_task("注册页面", "邮箱注册 + 密码强度提示", "high") create_task("忘记密码页面", "邮箱重置流程", "medium") create_task("邮箱验证提示", "注册后验证邮件提醒", "low") - + # Day 3-4: 权限 UI create_task("成员管理页面", "Workspace 成员列表 + 角色显示", "high") create_task("邀请成员弹窗", "输入邮箱 + 选择角色", "medium") create_task("角色选择器组件", "下拉选择 Owner/Admin/Member/Viewer", "low") create_task("权限说明文档", "各角色权限说明页面", "low") - + # Day 5-6: 订阅 UI create_task("套餐选择页面", "免费版/专业版/企业版对比表", "high") create_task("支付二维码页面", "显示支付宝/微信二维码 + 轮询支付状态", "high") create_task("账单管理页面", "历史账单列表 + 下载", "medium") create_task("配额使用展示", "当前 Workspace/项目/存储使用情况", "medium") - + # Day 7: 上线准备 create_task("Phase 4 端到端测试", "注册 → 邀请成员 → 付费 → 使用完整流程", "urgent") create_task("Phase 4 性能测试", "登录/权限检查响应时间测试", "high") create_task("Phase 4 安全审计", "SQL注入/XSS/CSRF/Token安全检查", "urgent") create_task("Phase 4 生产环境部署", "部署到服务器 + 灰度发布", "high") - + print("\n" + "=" * 60) print("[OK] Phase 4 所有任务已录入推进器!") print("=" * 60) print("\n访问推进器: http://47.98.113.167:8088/projects") print("共创建 4 个里程碑 + 68 个任务\n") + if __name__ == "__main__": main() diff --git a/scripts/init_phase6_tasks.py b/scripts/init_phase6_tasks.py index 349992feb..cf445f99a 100644 --- a/scripts/init_phase6_tasks.py +++ b/scripts/init_phase6_tasks.py @@ -2,6 +2,7 @@ Phase 6: 前端完善 - 任务初始化脚本 将 Phase 6 的所有任务同步到推进器 """ + import sqlite3 from datetime import datetime @@ -64,7 +65,6 @@ PHASE6_TASKS = [ "priority": "medium", "estimated_hours": 3, }, - # Week 3: 认证页面 (5个任务) { "name": "登录页面", @@ -106,7 +106,6 @@ PHASE6_TASKS = [ "priority": "high", "estimated_hours": 4, }, - # Week 4: 工作空间管理 (6个任务) { "name": "工作空间列表页面", @@ -156,7 +155,6 @@ PHASE6_TASKS = [ "priority": "medium", "estimated_hours": 3, }, - # Week 5: 订阅管理 (5个任务) { "name": "套餐选择页面", @@ -198,7 +196,6 @@ PHASE6_TASKS = [ "priority": "low", "estimated_hours": 3, }, - # Week 6: Admin 后台 (5个任务) { "name": "Dashboard 仪表盘", @@ -240,7 +237,6 @@ PHASE6_TASKS = [ "priority": "medium", "estimated_hours": 4, }, - # Week 7: 个人中心 (4个任务) { "name": "个人设置页面", @@ -274,7 +270,6 @@ PHASE6_TASKS = [ "priority": "medium", "estimated_hours": 4, }, - # Week 8: 测试和优化 (8个任务) { "name": "E2E 测试:认证流程", @@ -345,41 +340,44 @@ PHASE6_TASKS = [ def init_phase6_tasks(): """初始化 Phase 6 任务到数据库""" - + # 连接数据库 db_path = "F:/openclaw-saas/tracker.db" conn = sqlite3.connect(db_path) cursor = conn.cursor() - + print("[Phase 6] 开始初始化任务...") - + # 插入任务 created_count = 0 for task in PHASE6_TASKS: try: - cursor.execute(""" + cursor.execute( + """ INSERT INTO tasks ( name, description, status, phase, milestone, priority, estimated_hours, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, ( - task["name"], - task["description"], - "pending", - task["phase"], - task["milestone"], - task["priority"], - task["estimated_hours"], - datetime.now().isoformat() - )) + """, + ( + task["name"], + task["description"], + "pending", + task["phase"], + task["milestone"], + task["priority"], + task["estimated_hours"], + datetime.now().isoformat(), + ), + ) created_count += 1 print(f"[OK] {task['name']}") except Exception as e: print(f"[ERROR] {task['name']}: {e}") - + conn.commit() conn.close() - + print(f"\n[SUCCESS] Phase 6 任务初始化完成!") print(f"📊 总计 {len(PHASE6_TASKS)} 个任务") print(f"✅ 成功创建 {created_count} 个任务") diff --git a/scripts/init_tracker_data.py b/scripts/init_tracker_data.py index 9675bb52e..2cbc77257 100644 --- a/scripts/init_tracker_data.py +++ b/scripts/init_tracker_data.py @@ -4,9 +4,11 @@ 小虾 SaaS 项目推进器数据初始化脚本 将当前项目状态、规则、待办事项录入推进器 """ + import sys import io -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") import requests import json @@ -19,6 +21,7 @@ API_BASE = "http://47.98.113.167:8089/api/v1" PROJECT_ID = "00000000-0000-0000-0000-000000000001" WORKSPACE_ID = "00000000-0000-0000-0000-000000000001" + def create_milestone(name, target_date, description=""): """创建里程碑""" payload = { @@ -26,7 +29,7 @@ def create_milestone(name, target_date, description=""): "target_date": target_date, "project_id": PROJECT_ID, "workspace_id": WORKSPACE_ID, - "description": description + "description": description, } resp = requests.post(f"{API_BASE}/project-management/milestones", json=payload) if resp.status_code == 200: @@ -36,6 +39,7 @@ def create_milestone(name, target_date, description=""): print(f"[FAIL] 里程碑创建失败: {name} - {resp.text}") return None + def create_task(name, description, priority="medium", status="pending", progress=0, tags=None): """创建任务""" payload = { @@ -53,48 +57,49 @@ def create_task(name, description, priority="medium", status="pending", progress print(f"[FAIL] 任务创建失败: {name} - {resp.text}") return None + def main(): print("=" * 60) print("小虾 SaaS 项目推进器 - 数据初始化") print("=" * 60) - + # ========== 里程碑 ========== print("\n[1/4] 创建里程碑...") - + milestones = [ { "name": "Phase 1: 核心平台层完成", "target_date": "2026-06-15", - "description": "Clean Architecture 骨架 + 核心业务对象 + 完整测试" + "description": "Clean Architecture 骨架 + 核心业务对象 + 完整测试", }, { "name": "Phase 2: 项目管理模块落地", "target_date": "2026-06-16", - "description": "任务/里程碑/问题管理 + 前后端完整链路" + "description": "任务/里程碑/问题管理 + 前后端完整链路", }, { "name": "Phase 3: 部署与备案完成", "target_date": "2026-06-30", - "description": "生产环境部署 + HTTPS 证书 + 域名备案通过" + "description": "生产环境部署 + HTTPS 证书 + 域名备案通过", }, { "name": "Phase 4: SAAS 产品化完成", "target_date": "2026-07-15", - "description": "多租户 + 权限体系 + 订阅计费" + "description": "多租户 + 权限体系 + 订阅计费", }, { "name": "Phase 5: AI 剪辑能力接入", "target_date": "2026-08-01", - "description": "视频分类模型 + 自动剪辑 + 配音合成" - } + "description": "视频分类模型 + 自动剪辑 + 配音合成", + }, ] - + for m in milestones: create_milestone(m["name"], m["target_date"], m["description"]) - + # ========== 已完成任务 ========== print("\n[OK] 录入已完成任务...") - + completed_tasks = [ { "name": "Clean Architecture 架构设计", @@ -102,7 +107,7 @@ def main(): "status": "COMPLETED", "progress": 100, "priority": "urgent", - "tags": ["架构", "Phase1"] + "tags": ["架构", "Phase1"], }, { "name": "核心业务对象实现", @@ -110,7 +115,7 @@ def main(): "status": "COMPLETED", "progress": 100, "priority": "high", - "tags": ["业务逻辑", "Phase1"] + "tags": ["业务逻辑", "Phase1"], }, { "name": "双持久化实现", @@ -118,7 +123,7 @@ def main(): "status": "COMPLETED", "progress": 100, "priority": "high", - "tags": ["数据库", "Phase1"] + "tags": ["数据库", "Phase1"], }, { "name": "项目管理模块开发", @@ -126,7 +131,7 @@ def main(): "status": "COMPLETED", "progress": 100, "priority": "urgent", - "tags": ["项目管理", "Phase2"] + "tags": ["项目管理", "Phase2"], }, { "name": "项目推进器前端开发", @@ -134,7 +139,7 @@ def main(): "status": "COMPLETED", "progress": 100, "priority": "high", - "tags": ["前端", "Phase2"] + "tags": ["前端", "Phase2"], }, { "name": "Docker Compose 部署", @@ -142,7 +147,7 @@ def main(): "status": "COMPLETED", "progress": 100, "priority": "high", - "tags": ["部署", "Phase3"] + "tags": ["部署", "Phase3"], }, { "name": "Nginx 反向代理配置", @@ -150,16 +155,16 @@ def main(): "status": "COMPLETED", "progress": 100, "priority": "medium", - "tags": ["部署", "Phase3"] - } + "tags": ["部署", "Phase3"], + }, ] - + for task in completed_tasks: create_task(**task) - + # ========== 进行中任务 ========== print("\n[2/4] 录入进行中任务...") - + in_progress_tasks = [ { "name": "域名备案审核", @@ -167,7 +172,7 @@ def main(): "status": "IN_PROGRESS", "progress": 50, "priority": "urgent", - "tags": ["部署", "Phase3", "阻塞"] + "tags": ["部署", "Phase3", "阻塞"], }, { "name": "HTTPS 证书申请", @@ -175,16 +180,16 @@ def main(): "status": "BLOCKED", "progress": 0, "priority": "high", - "tags": ["部署", "Phase3", "依赖备案"] - } + "tags": ["部署", "Phase3", "依赖备案"], + }, ] - + for task in in_progress_tasks: create_task(**task) - + # ========== 待办任务 ========== print("\n[3/4] 录入待办任务...") - + pending_tasks = [ { "name": "切换到正式域名和 HTTPS", @@ -192,7 +197,7 @@ def main(): "status": "PENDING", "progress": 0, "priority": "high", - "tags": ["部署", "Phase3", "依赖备案"] + "tags": ["部署", "Phase3", "依赖备案"], }, { "name": "PostgreSQL 生产环境切换", @@ -200,7 +205,7 @@ def main(): "status": "PENDING", "progress": 0, "priority": "high", - "tags": ["数据库", "Phase3"] + "tags": ["数据库", "Phase3"], }, { "name": "前端环境变量配置", @@ -208,7 +213,7 @@ def main(): "status": "PENDING", "progress": 0, "priority": "medium", - "tags": ["前端", "Phase3", "依赖备案"] + "tags": ["前端", "Phase3", "依赖备案"], }, { "name": "甘特图视图开发", @@ -216,7 +221,7 @@ def main(): "status": "PENDING", "progress": 0, "priority": "medium", - "tags": ["前端", "Phase2"] + "tags": ["前端", "Phase2"], }, { "name": "批量操作 API", @@ -224,7 +229,7 @@ def main(): "status": "PENDING", "progress": 0, "priority": "low", - "tags": ["后端", "Phase2"] + "tags": ["后端", "Phase2"], }, { "name": "数据导出功能", @@ -232,7 +237,7 @@ def main(): "status": "PENDING", "progress": 0, "priority": "low", - "tags": ["功能", "Phase2"] + "tags": ["功能", "Phase2"], }, { "name": "多租户权限体系", @@ -240,7 +245,7 @@ def main(): "status": "PENDING", "progress": 0, "priority": "urgent", - "tags": ["权限", "Phase4"] + "tags": ["权限", "Phase4"], }, { "name": "认证与账号体系", @@ -248,7 +253,7 @@ def main(): "status": "PENDING", "progress": 0, "priority": "urgent", - "tags": ["认证", "Phase4"] + "tags": ["认证", "Phase4"], }, { "name": "订阅与计费体系", @@ -256,7 +261,7 @@ def main(): "status": "PENDING", "progress": 0, "priority": "high", - "tags": ["商业化", "Phase4"] + "tags": ["商业化", "Phase4"], }, { "name": "视频分类模型接入", @@ -264,7 +269,7 @@ def main(): "status": "PENDING", "progress": 0, "priority": "urgent", - "tags": ["AI", "Phase5"] + "tags": ["AI", "Phase5"], }, { "name": "自动剪辑能力", @@ -272,7 +277,7 @@ def main(): "status": "PENDING", "progress": 0, "priority": "high", - "tags": ["AI", "Phase5"] + "tags": ["AI", "Phase5"], }, { "name": "配音合成能力", @@ -280,18 +285,19 @@ def main(): "status": "PENDING", "progress": 0, "priority": "medium", - "tags": ["AI", "Phase5"] - } + "tags": ["AI", "Phase5"], + }, ] - + for task in pending_tasks: create_task(**task) - + print("\n" + "=" * 60) print("[OK] 数据初始化完成!") print("=" * 60) print(f"\n访问推进器: http://47.98.113.167:8088/projects") print(f"访问 API 文档: http://47.98.113.167:8089/docs\n") + if __name__ == "__main__": main() diff --git a/scripts/init_tracker_direct.py b/scripts/init_tracker_direct.py index 4e3354c47..476e6923c 100644 --- a/scripts/init_tracker_direct.py +++ b/scripts/init_tracker_direct.py @@ -3,35 +3,40 @@ """ 直接写入 tracker.db 的脚本 """ + import sqlite3 from datetime import datetime DB_PATH = "tracker.db" + def init_database(): """初始化所有 Phase 4 和 Phase 6 的任务数据""" conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() - + # 清空现有数据 cursor.execute("DELETE FROM logs") cursor.execute("DELETE FROM tasks") cursor.execute("DELETE FROM milestones") - + print("=" * 60) print("初始化 xiaoxia-saas 项目数据到 tracker.db") print("=" * 60) - + # ========== Phase 4: SAAS 产品化 ========== print("\n[Phase 4] 初始化任务...") - + # Milestone 1: 认证与账号体系 - cursor.execute(""" + cursor.execute( + """ INSERT INTO milestones (name, phase, start_date, end_date, status, description) VALUES (?, ?, ?, ?, ?, ?) - """, ("认证与账号体系", "Phase 4", "2026-06-10", "2026-06-17", "completed", "JWT 登录、注册、密码管理")) + """, + ("认证与账号体系", "Phase 4", "2026-06-10", "2026-06-17", "completed", "JWT 登录、注册、密码管理"), + ) milestone1_id = cursor.lastrowid - + auth_tasks = [ ("JWT 工具类实现", "sign/verify/refresh Token 功能", "completed", "high"), ("bcrypt 密码哈希工具", "密码加密存储", "completed", "high"), @@ -44,19 +49,25 @@ def init_database(): ("刷新 Token API", "用 refresh_token 换取新 access_token", "completed", "high"), ("密码重置流程", "忘记密码 + 邮件重置", "completed", "medium"), ] - + for name, desc, status, priority in auth_tasks: - cursor.execute(""" + cursor.execute( + """ INSERT INTO tasks (name, description, status, phase, milestone, priority, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (name, desc, status, "Phase 4", "认证与账号体系", priority, datetime.now().isoformat())) - + """, + (name, desc, status, "Phase 4", "认证与账号体系", priority, datetime.now().isoformat()), + ) + # Milestone 2: 多租户权限体系 - cursor.execute(""" + cursor.execute( + """ INSERT INTO milestones (name, phase, start_date, end_date, status, description) VALUES (?, ?, ?, ?, ?, ?) - """, ("多租户权限体系", "Phase 4", "2026-06-10", "2026-06-17", "completed", "4 种角色、成员管理、邀请系统")) - + """, + ("多租户权限体系", "Phase 4", "2026-06-10", "2026-06-17", "completed", "4 种角色、成员管理、邀请系统"), + ) + permission_tasks = [ ("WorkspaceMembership 实体", "成员关系实体设计", "completed", "high"), ("WorkspaceRole 枚举", "owner/admin/member/viewer", "completed", "high"), @@ -69,19 +80,25 @@ def init_database(): ("转让 Workspace 所有权", "转让 owner 角色", "completed", "low"), ("权限矩阵验证", "测试所有权限组合", "completed", "high"), ] - + for name, desc, status, priority in permission_tasks: - cursor.execute(""" + cursor.execute( + """ INSERT INTO tasks (name, description, status, phase, milestone, priority, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (name, desc, status, "Phase 4", "多租户权限体系", priority, datetime.now().isoformat())) - + """, + (name, desc, status, "Phase 4", "多租户权限体系", priority, datetime.now().isoformat()), + ) + # Milestone 3: 订阅与计费 - cursor.execute(""" + cursor.execute( + """ INSERT INTO milestones (name, phase, start_date, end_date, status, description) VALUES (?, ?, ?, ?, ?, ?) - """, ("订阅与计费体系", "Phase 4", "2026-06-10", "2026-06-17", "completed", "Free/Pro/Enterprise 套餐")) - + """, + ("订阅与计费体系", "Phase 4", "2026-06-10", "2026-06-17", "completed", "Free/Pro/Enterprise 套餐"), + ) + subscription_tasks = [ ("Subscription 实体与 Repository", "订阅数据模型", "completed", "high"), ("SubscriptionPlan 枚举", "free/pro/enterprise", "completed", "high"), @@ -94,24 +111,30 @@ def init_database(): ("Invoice 实体与 Repository", "账单数据模型", "completed", "medium"), ("生成账单 PDF", "PDF 账单生成", "pending", "low"), ] - + for name, desc, status, priority in subscription_tasks: - cursor.execute(""" + cursor.execute( + """ INSERT INTO tasks (name, description, status, phase, milestone, priority, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (name, desc, status, "Phase 4", "订阅与计费体系", priority, datetime.now().isoformat())) - + """, + (name, desc, status, "Phase 4", "订阅与计费体系", priority, datetime.now().isoformat()), + ) + print("[OK] Phase 4 任务已录入") - + # ========== Phase 6: 前端完善 ========== print("\n[Phase 6] 初始化任务...") - + # Milestone 1: 基础搭建 - cursor.execute(""" + cursor.execute( + """ INSERT INTO milestones (name, phase, start_date, end_date, status, description) VALUES (?, ?, ?, ?, ?, ?) - """, ("前端基础搭建", "Phase 6", "2026-06-17", "2026-06-17", "completed", "Vite + React + TypeScript")) - + """, + ("前端基础搭建", "Phase 6", "2026-06-17", "2026-06-17", "completed", "Vite + React + TypeScript"), + ) + foundation_tasks = [ ("项目初始化:Vite + React + TypeScript", "创建 Vite 项目", "completed", "high"), ("安装配置依赖包", "Ant Design, React Router 等", "completed", "high"), @@ -121,19 +144,25 @@ def init_database(): ("设计系统配置", "CSS 变量、色彩系统", "completed", "medium"), ("TypeScript 类型定义", "API 响应、实体类型", "completed", "medium"), ] - + for name, desc, status, priority in foundation_tasks: - cursor.execute(""" + cursor.execute( + """ INSERT INTO tasks (name, description, status, phase, milestone, priority, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (name, desc, status, "Phase 6", "前端基础搭建", priority, datetime.now().isoformat())) - + """, + (name, desc, status, "Phase 6", "前端基础搭建", priority, datetime.now().isoformat()), + ) + # Milestone 2: 认证页面 - cursor.execute(""" + cursor.execute( + """ INSERT INTO milestones (name, phase, start_date, end_date, status, description) VALUES (?, ?, ?, ?, ?, ?) - """, ("认证系统", "Phase 6", "2026-06-17", "2026-06-17", "completed", "登录、注册、密码重置")) - + """, + ("认证系统", "Phase 6", "2026-06-17", "2026-06-17", "completed", "登录、注册、密码重置"), + ) + auth_ui_tasks = [ ("登录页面", "用户登录界面和逻辑", "completed", "high"), ("注册页面", "用户注册界面和验证", "completed", "high"), @@ -141,19 +170,25 @@ def init_database(): ("重置密码页面", "密码重置界面", "completed", "high"), ("Token 管理和刷新", "自动 token 刷新", "completed", "high"), ] - + for name, desc, status, priority in auth_ui_tasks: - cursor.execute(""" + cursor.execute( + """ INSERT INTO tasks (name, description, status, phase, milestone, priority, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (name, desc, status, "Phase 6", "认证系统", priority, datetime.now().isoformat())) - + """, + (name, desc, status, "Phase 6", "认证系统", priority, datetime.now().isoformat()), + ) + # Milestone 3: 工作空间管理 - cursor.execute(""" + cursor.execute( + """ INSERT INTO milestones (name, phase, start_date, end_date, status, description) VALUES (?, ?, ?, ?, ?, ?) - """, ("工作空间管理", "Phase 6", "2026-06-17", "2026-06-17", "completed", "工作空间 CRUD、成员管理")) - + """, + ("工作空间管理", "Phase 6", "2026-06-17", "2026-06-17", "completed", "工作空间 CRUD、成员管理"), + ) + workspace_tasks = [ ("工作空间列表页面", "显示所有工作空间", "completed", "high"), ("工作空间详情页面", "工作空间概览、配额", "completed", "high"), @@ -162,19 +197,25 @@ def init_database(): ("权限矩阵展示", "各角色权限说明", "completed", "medium"), ("工作空间设置", "修改名称、删除", "completed", "medium"), ] - + for name, desc, status, priority in workspace_tasks: - cursor.execute(""" + cursor.execute( + """ INSERT INTO tasks (name, description, status, phase, milestone, priority, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (name, desc, status, "Phase 6", "工作空间管理", priority, datetime.now().isoformat())) - + """, + (name, desc, status, "Phase 6", "工作空间管理", priority, datetime.now().isoformat()), + ) + # Milestone 4: 订阅管理 - cursor.execute(""" + cursor.execute( + """ INSERT INTO milestones (name, phase, start_date, end_date, status, description) VALUES (?, ?, ?, ?, ?, ?) - """, ("订阅管理", "Phase 6", "2026-06-17", "2026-06-17", "completed", "套餐选择、升级流程")) - + """, + ("订阅管理", "Phase 6", "2026-06-17", "2026-06-17", "completed", "套餐选择、升级流程"), + ) + subscription_ui_tasks = [ ("套餐选择页面", "Free/Pro/Enterprise 对比", "completed", "high"), ("升级流程", "订阅升级流程和确认", "completed", "high"), @@ -182,19 +223,25 @@ def init_database(): ("账单页面", "历史账单列表", "completed", "medium"), ("发票申请入口", "发票申请表单", "completed", "low"), ] - + for name, desc, status, priority in subscription_ui_tasks: - cursor.execute(""" + cursor.execute( + """ INSERT INTO tasks (name, description, status, phase, milestone, priority, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (name, desc, status, "Phase 6", "订阅管理", priority, datetime.now().isoformat())) - + """, + (name, desc, status, "Phase 6", "订阅管理", priority, datetime.now().isoformat()), + ) + # Milestone 5: Admin 后台 - cursor.execute(""" + cursor.execute( + """ INSERT INTO milestones (name, phase, start_date, end_date, status, description) VALUES (?, ?, ?, ?, ?, ?) - """, ("Admin 后台", "Phase 6", "2026-06-17", "2026-06-17", "completed", "Dashboard、用户管理")) - + """, + ("Admin 后台", "Phase 6", "2026-06-17", "2026-06-17", "completed", "Dashboard、用户管理"), + ) + admin_tasks = [ ("Dashboard 仪表盘", "关键指标和图表", "completed", "high"), ("用户管理页面", "用户列表、搜索", "completed", "high"), @@ -202,38 +249,50 @@ def init_database(): ("系统监控页面", "API 性能、数据库状态", "completed", "medium"), ("日志查看器", "错误日志和慢查询", "completed", "medium"), ] - + for name, desc, status, priority in admin_tasks: - cursor.execute(""" + cursor.execute( + """ INSERT INTO tasks (name, description, status, phase, milestone, priority, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (name, desc, status, "Phase 6", "Admin 后台", priority, datetime.now().isoformat())) - + """, + (name, desc, status, "Phase 6", "Admin 后台", priority, datetime.now().isoformat()), + ) + # Milestone 6: 个人中心 - cursor.execute(""" + cursor.execute( + """ INSERT INTO milestones (name, phase, start_date, end_date, status, description) VALUES (?, ?, ?, ?, ?, ?) - """, ("个人中心", "Phase 6", "2026-06-17", "2026-06-17", "completed", "个人设置、安全设置")) - + """, + ("个人中心", "Phase 6", "2026-06-17", "2026-06-17", "completed", "个人设置、安全设置"), + ) + profile_tasks = [ ("个人设置页面", "基本信息、头像上传", "completed", "medium"), ("账号安全设置", "修改密码、修改邮箱", "completed", "high"), ("通知设置", "邮件通知、类型选择", "completed", "medium"), ("Session 管理", "设备列表、登出设备", "completed", "medium"), ] - + for name, desc, status, priority in profile_tasks: - cursor.execute(""" + cursor.execute( + """ INSERT INTO tasks (name, description, status, phase, milestone, priority, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (name, desc, status, "Phase 6", "个人中心", priority, datetime.now().isoformat())) - + """, + (name, desc, status, "Phase 6", "个人中心", priority, datetime.now().isoformat()), + ) + # Milestone 7: 测试与优化 - cursor.execute(""" + cursor.execute( + """ INSERT INTO milestones (name, phase, start_date, end_date, status, description) VALUES (?, ?, ?, ?, ?, ?) - """, ("测试与优化", "Phase 6", "2026-06-17", "2026-06-17", "completed", "单元测试、E2E 测试、性能优化")) - + """, + ("测试与优化", "Phase 6", "2026-06-17", "2026-06-17", "completed", "单元测试、E2E 测试、性能优化"), + ) + test_tasks = [ ("单元测试", "Vitest + React Testing Library", "completed", "high"), ("E2E 测试:认证流程", "Playwright 自动化测试", "completed", "high"), @@ -244,29 +303,32 @@ def init_database(): ("可访问性优化", "WCAG 2.1 AA 标准", "completed", "medium"), ("移动端适配优化", "响应式设计验证", "completed", "medium"), ] - + for name, desc, status, priority in test_tasks: - cursor.execute(""" + cursor.execute( + """ INSERT INTO tasks (name, description, status, phase, milestone, priority, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (name, desc, status, "Phase 6", "测试与优化", priority, datetime.now().isoformat())) - + """, + (name, desc, status, "Phase 6", "测试与优化", priority, datetime.now().isoformat()), + ) + print("[OK] Phase 6 任务已录入") - + conn.commit() - + # 统计 cursor.execute("SELECT COUNT(*) FROM milestones") milestone_count = cursor.fetchone()[0] - + cursor.execute("SELECT COUNT(*) FROM tasks") task_count = cursor.fetchone()[0] - + cursor.execute("SELECT phase, COUNT(*) FROM tasks GROUP BY phase") phase_stats = cursor.fetchall() - + conn.close() - + print("\n" + "=" * 60) print("[SUCCESS] 数据初始化完成!") print("=" * 60) @@ -278,5 +340,6 @@ def init_database(): print("\n推进器地址: http://47.98.113.167:8088/projects") print("=" * 60) + if __name__ == "__main__": init_database() diff --git a/scripts/smoke_external_services.py b/scripts/smoke_external_services.py index a21176b95..e3911193e 100644 --- a/scripts/smoke_external_services.py +++ b/scripts/smoke_external_services.py @@ -113,9 +113,15 @@ def smoke_oss(strict: bool) -> None: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--strict", action="store_true", help="Fail when disabled/unconfigured services are missing.") - parser.add_argument("--skip-smtp", action="store_true", help="Skip SMTP smoke when email delivery is intentionally disabled.") - parser.add_argument("--skip-redis", action="store_true", help="Skip Redis session smoke when sessions are intentionally disabled.") - parser.add_argument("--skip-oss", action="store_true", help="Skip OSS smoke when object storage is intentionally disabled.") + parser.add_argument( + "--skip-smtp", action="store_true", help="Skip SMTP smoke when email delivery is intentionally disabled." + ) + parser.add_argument( + "--skip-redis", action="store_true", help="Skip Redis session smoke when sessions are intentionally disabled." + ) + parser.add_argument( + "--skip-oss", action="store_true", help="Skip OSS smoke when object storage is intentionally disabled." + ) parser.add_argument( "--send-email-to", help="Actually send a test email to this address. Otherwise SMTP only connects/logs in." ) diff --git a/scripts/smoke_public_boundary_flow.py b/scripts/smoke_public_boundary_flow.py index e3e343bcc..577b7bb45 100644 --- a/scripts/smoke_public_boundary_flow.py +++ b/scripts/smoke_public_boundary_flow.py @@ -102,7 +102,9 @@ def main() -> None: for index in range(8): task_response = owner.get(f"{BASE_URL}/generation/tasks/{task_id}", headers=owner_headers, timeout=30) print(f"owner_generation_{index}={task_response.status_code}") - results_response = owner.get(f"{BASE_URL}/generation/tasks/{task_id}/results", headers=owner_headers, timeout=30) + results_response = owner.get( + f"{BASE_URL}/generation/tasks/{task_id}/results", headers=owner_headers, timeout=30 + ) print(f"owner_generation_results_{index}={results_response.status_code}") if results_response.status_code == 200: items = results_response.json().get("items", []) @@ -114,20 +116,68 @@ def main() -> None: checks = [ ("anon_me", requests.get(f"{BASE_URL}/auth/me", timeout=30).status_code, {401, 403}), ("anon_workspaces", requests.get(f"{BASE_URL}/workspaces", timeout=30).status_code, {401, 403}), - ("anon_project_get", requests.get(f"{BASE_URL}/projects/{project_id}", timeout=30).status_code, {401, 403, 404}), - ("intruder_project_get", intruder.get(f"{BASE_URL}/projects/{project_id}", headers=intruder_headers, timeout=30).status_code, {403, 404}), - ("intruder_library_get", intruder.get(f"{BASE_URL}/asset-libraries/{library_id}", headers=intruder_headers, timeout=30).status_code, {403, 404}), - ("anon_assets", requests.get(f"{BASE_URL}/assets", params={"library_id": library_id}, timeout=30).status_code, {401, 403, 404}), - ("intruder_assets", intruder.get(f"{BASE_URL}/assets", params={"library_id": library_id}, headers=intruder_headers, timeout=30).status_code, {403, 404}), - ("anon_generation_task", requests.get(f"{BASE_URL}/generation/tasks/{task_id}", timeout=30).status_code, {401, 403, 404}), - ("intruder_generation_task", intruder.get(f"{BASE_URL}/generation/tasks/{task_id}", headers=intruder_headers, timeout=30).status_code, {403, 404}), - ("intruder_generation_results", intruder.get(f"{BASE_URL}/generation/tasks/{task_id}/results", headers=intruder_headers, timeout=30).status_code, {403, 404}), + ( + "anon_project_get", + requests.get(f"{BASE_URL}/projects/{project_id}", timeout=30).status_code, + {401, 403, 404}, + ), + ( + "intruder_project_get", + intruder.get(f"{BASE_URL}/projects/{project_id}", headers=intruder_headers, timeout=30).status_code, + {403, 404}, + ), + ( + "intruder_library_get", + intruder.get(f"{BASE_URL}/asset-libraries/{library_id}", headers=intruder_headers, timeout=30).status_code, + {403, 404}, + ), + ( + "anon_assets", + requests.get(f"{BASE_URL}/assets", params={"library_id": library_id}, timeout=30).status_code, + {401, 403, 404}, + ), + ( + "intruder_assets", + intruder.get( + f"{BASE_URL}/assets", params={"library_id": library_id}, headers=intruder_headers, timeout=30 + ).status_code, + {403, 404}, + ), + ( + "anon_generation_task", + requests.get(f"{BASE_URL}/generation/tasks/{task_id}", timeout=30).status_code, + {401, 403, 404}, + ), + ( + "intruder_generation_task", + intruder.get(f"{BASE_URL}/generation/tasks/{task_id}", headers=intruder_headers, timeout=30).status_code, + {403, 404}, + ), + ( + "intruder_generation_results", + intruder.get( + f"{BASE_URL}/generation/tasks/{task_id}/results", headers=intruder_headers, timeout=30 + ).status_code, + {403, 404}, + ), ] if video_id: checks.extend( [ - ("intruder_video_get", intruder.get(f"{BASE_URL}/generated-videos/{video_id}", headers=intruder_headers, timeout=30).status_code, {403, 404}), - ("intruder_video_download", intruder.get(f"{BASE_URL}/generated-videos/{video_id}/download-url", headers=intruder_headers, timeout=30).status_code, {403, 404}), + ( + "intruder_video_get", + intruder.get( + f"{BASE_URL}/generated-videos/{video_id}", headers=intruder_headers, timeout=30 + ).status_code, + {403, 404}, + ), + ( + "intruder_video_download", + intruder.get( + f"{BASE_URL}/generated-videos/{video_id}/download-url", headers=intruder_headers, timeout=30 + ).status_code, + {403, 404}, + ), ] ) upload_response = intruder.post( diff --git a/tests/unit/test_generation_preflight.py b/tests/unit/test_generation_preflight.py index 955d65da3..a0eab4849 100644 --- a/tests/unit/test_generation_preflight.py +++ b/tests/unit/test_generation_preflight.py @@ -25,16 +25,20 @@ def _asset(name: str, mime_type: str, status: AssetStatus) -> Asset: def test_generation_preflight_rejects_library_without_ready_video(): with pytest.raises(HTTPException) as exc_info: - _ensure_library_has_ready_video_assets([ - _asset("image.jpg", "image/jpeg", AssetStatus.READY), - _asset("video.mp4", "video/mp4", AssetStatus.UPLOADING), - ]) + _ensure_library_has_ready_video_assets( + [ + _asset("image.jpg", "image/jpeg", AssetStatus.READY), + _asset("video.mp4", "video/mp4", AssetStatus.UPLOADING), + ] + ) assert exc_info.value.status_code == 422 assert "ready 状态的视频素材" in exc_info.value.detail def test_generation_preflight_accepts_ready_video(): - _ensure_library_has_ready_video_assets([ - _asset("video.mp4", "video/mp4", AssetStatus.READY), - ]) + _ensure_library_has_ready_video_assets( + [ + _asset("video.mp4", "video/mp4", AssetStatus.READY), + ] + ) diff --git a/tests/unit/test_production_resource_monitoring.py b/tests/unit/test_production_resource_monitoring.py index 53038161f..cc17b42c9 100644 --- a/tests/unit/test_production_resource_monitoring.py +++ b/tests/unit/test_production_resource_monitoring.py @@ -4,9 +4,9 @@ from pathlib import Path def test_production_resource_check_writes_duty_report(): script = Path("scripts/production_resource_check.sh").read_text(encoding="utf-8") - assert "REPORT_PATH=\"${REPORT_PATH:-/var/lib/xiaoxia-ci/duty_report.json}\"" in script - assert "API_HEALTH_URL=\"${API_HEALTH_URL:-http://127.0.0.1:8001/health}\"" in script - assert "WEB_HEALTH_URL=\"${WEB_HEALTH_URL:-http://127.0.0.1:3002/}\"" in script + assert 'REPORT_PATH="${REPORT_PATH:-/var/lib/xiaoxia-ci/duty_report.json}"' in script + assert 'API_HEALTH_URL="${API_HEALTH_URL:-http://127.0.0.1:8001/health}"' in script + assert 'WEB_HEALTH_URL="${WEB_HEALTH_URL:-http://127.0.0.1:3002/}"' in script assert "EXPECTED_VERSION" in script assert "json.dump(report" in script assert "os.replace(tmp_path, report_path)" in script @@ -15,10 +15,10 @@ def test_production_resource_check_writes_duty_report(): def test_production_resource_check_has_resource_thresholds(): script = Path("scripts/production_resource_check.sh").read_text(encoding="utf-8") - assert "LOAD_WARN_PER_CPU=\"${LOAD_WARN_PER_CPU:-1.5}\"" in script - assert "MEM_AVAILABLE_WARN_MB=\"${MEM_AVAILABLE_WARN_MB:-256}\"" in script - assert "DISK_WARN_PERCENT=\"${DISK_WARN_PERCENT:-85}\"" in script - assert "SWAP_USED_WARN_PERCENT=\"${SWAP_USED_WARN_PERCENT:-60}\"" in script + assert 'LOAD_WARN_PER_CPU="${LOAD_WARN_PER_CPU:-1.5}"' in script + assert 'MEM_AVAILABLE_WARN_MB="${MEM_AVAILABLE_WARN_MB:-256}"' in script + assert 'DISK_WARN_PERCENT="${DISK_WARN_PERCENT:-85}"' in script + assert 'SWAP_USED_WARN_PERCENT="${SWAP_USED_WARN_PERCENT:-60}"' in script assert "未启用 swap" in script assert "根分区磁盘使用率偏高" in script diff --git a/tests/unit/test_release_scripts.py b/tests/unit/test_release_scripts.py index a91a1455a..5bf3c4d6c 100644 --- a/tests/unit/test_release_scripts.py +++ b/tests/unit/test_release_scripts.py @@ -32,23 +32,23 @@ def test_deploy_production_uses_production_infra_and_project(): assert "Skipping production API/worker image builds" in script assert "RELEASE_VERSION" in script assert "RUNTIME_IMAGE_TAR" in script - assert "docker load -i \"$RUNTIME_IMAGE_TAR\"" in script + assert 'docker load -i "$RUNTIME_IMAGE_TAR"' in script assert 'export APP_VERSION="$RELEASE_VERSION"' in script assert 'export WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-1}"' in script assert 'export WORKER_MAX_TASKS_PER_CHILD="${WORKER_MAX_TASKS_PER_CHILD:-100}"' in script - assert "docker image inspect \"${API_IMAGE:-xiaoxia-saas-api:dev}\"" in script - assert "docker image inspect \"${WORKER_IMAGE:-xiaoxia-saas-worker:dev}\"" in script + assert 'docker image inspect "${API_IMAGE:-xiaoxia-saas-api:dev}"' in script + assert 'docker image inspect "${WORKER_IMAGE:-xiaoxia-saas-worker:dev}"' in script assert "ALLOW_PRODUCTION_BUILDS=true" in script assert "xiaoxia-postgres-production" in script assert "xiaoxia-redis-production" in script assert "xiaoxia-postgres\n" not in script assert "xiaoxia-redis\n" not in script assert "COMPOSE_PROJECT_NAME=xiaoxia-production-app" in script - assert "--env-file \"$ENV_FILE\"" in script + assert '--env-file "$ENV_FILE"' in script assert "python /app/scripts/validate_release_env.py --from-environ --strict-external" in script assert "alembic upgrade head" in script - assert "docker compose --env-file \"$ENV_FILE\" up -d api worker" in script - assert "docker compose --env-file \"$ENV_FILE\" up -d --force-recreate web" in script + assert 'docker compose --env-file "$ENV_FILE" up -d api worker' in script + assert 'docker compose --env-file "$ENV_FILE" up -d --force-recreate web' in script assert "nginx resolves the current API container IP" in script assert "/etc/cron.d/xiaoxia-production-resource-check" in script assert "EXPECTED_VERSION=$RELEASE_VERSION" in script @@ -58,10 +58,10 @@ def test_production_workflow_preserves_previous_web_assets(): workflow = Path(".gitea/workflows/deploy.yml").read_text(encoding="utf-8") production_section = workflow.split("deploy-production:", 1)[1] - assert "old_assets_dir=\"/tmp/xiaoxia-previous-web-assets-${RELEASE_VERSION}\"" in production_section - assert "docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. \"$old_assets_dir\"/" in production_section - assert "cp -a /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/. \"$old_assets_dir\"/" in production_section - assert "if [ ! -e \"/var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/$name\" ]; then" in production_section + assert 'old_assets_dir="/tmp/xiaoxia-previous-web-assets-${RELEASE_VERSION}"' in production_section + assert 'docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. "$old_assets_dir"/' in production_section + assert 'cp -a /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/. "$old_assets_dir"/' in production_section + assert 'if [ ! -e "/var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/$name" ]; then' in production_section def test_production_deploy_recreates_web_after_api_for_nginx_dns(): @@ -82,7 +82,7 @@ def test_production_nginx_static_upstream_requires_web_recreate(): assert "client_max_body_size 800m;" in config assert "proxy_read_timeout 300s;" in config assert "proxy_request_buffering off;" in config - assert 'location = /index.html' in config + assert "location = /index.html" in config assert 'Cache-Control "no-store, no-cache, must-revalidate" always' in config assert "--force-recreate web" in script @@ -128,7 +128,7 @@ def test_deploy_scripts_build_web_image_explicitly(): compose = Path("infra/docker/compose.yml").read_text(encoding="utf-8") assert "docker compose build --pull=false web" in staging_script - assert "docker compose --env-file \"$ENV_FILE\" build --pull=false web" in production_script + assert 'docker compose --env-file "$ENV_FILE" build --pull=false web' in production_script assert "dockerfile: ${WEB_DOCKERFILE:-infra/docker/web.Dockerfile}" in compose assert "NGINX_CONF: ${WEB_NGINX_CONF:-infra/docker/nginx.conf}" in compose assert "APP_VERSION: ${APP_VERSION:-0.1.0}" in compose @@ -139,8 +139,8 @@ def test_production_deploy_prunes_old_unused_docker_artifacts(): script = Path("infra/docker/deploy-production.sh").read_text(encoding="utf-8") assert "PRUNE_UNUSED_DOCKER_AFTER_DEPLOY" in script - assert "docker image prune -af --filter \"until=168h\"" in script - assert "docker builder prune -af --filter \"until=168h\"" in script + assert 'docker image prune -af --filter "until=168h"' in script + assert 'docker builder prune -af --filter "until=168h"' in script def test_worker_runtime_is_constrained_by_environment(): @@ -149,6 +149,7 @@ def test_worker_runtime_is_constrained_by_environment(): assert "--concurrency=${WORKER_CONCURRENCY:-1}" in dockerfile assert "--max-tasks-per-child=${WORKER_MAX_TASKS_PER_CHILD:-100}" in dockerfile + def test_production_nginx_proxies_to_production_api_container(): config = Path("infra/docker/nginx-production.conf").read_text(encoding="utf-8") @@ -218,7 +219,7 @@ def test_gitea_production_deploy_requires_runtime_builder_job(): production_section = workflow.split("deploy-production:", 1)[1] assert "runs-on: runtime-builder" in build_section - assert "scripts/build_release_images.sh \"${GITHUB_REF_NAME}\"" in build_section + assert 'scripts/build_release_images.sh "${GITHUB_REF_NAME}"' in build_section assert "GITHUB_TOKEN: ${{ github.token }}" in build_section assert "docker.m.daocloud.io/library/node:20" in build_section assert "npm ci && npm run build" in build_section @@ -234,10 +235,12 @@ def test_gitea_production_deploy_requires_runtime_builder_job(): assert "needs: build-production-runtime-images" in production_section assert "runs-on: runtime-builder" in production_section assert "Deploy production over SSH" in production_section - assert "release_tar=\"/var/lib/xiaoxia-saas-production/release-${RELEASE_VERSION}.tar.gz\"" in production_section + assert 'release_tar="/var/lib/xiaoxia-saas-production/release-${RELEASE_VERSION}.tar.gz"' in production_section assert "runtime-images-${RELEASE_VERSION}.tar" in production_section assert "apps/web/dist/index.html" in production_section - assert "HOST_PREFIX= sh /var/lib/xiaoxia-saas-production/repo/infra/docker/deploy-production.sh" in production_section + assert ( + "HOST_PREFIX= sh /var/lib/xiaoxia-saas-production/repo/infra/docker/deploy-production.sh" in production_section + ) def test_build_host_runbook_requires_off_production_runtime_builds(): @@ -283,12 +286,12 @@ def test_runtime_image_release_scripts_keep_builds_off_production(): assert "Refusing to build runtime images on a host that is running production services." in build_script assert "ALLOW_SHARED_PRODUCTION_BUILD_HOST=true" in build_script assert "docker load -i" in deploy_script - assert "API_IMAGE=\"xiaoxia-saas-api:$VERSION\"" in deploy_script - assert "WORKER_IMAGE=\"xiaoxia-saas-worker:$VERSION\"" in deploy_script + assert 'API_IMAGE="xiaoxia-saas-api:$VERSION"' in deploy_script + assert 'WORKER_IMAGE="xiaoxia-saas-worker:$VERSION"' in deploy_script assert "ALLOW_PRODUCTION_BUILDS=false" in deploy_script - assert "docker compose --env-file \"$ENV_FILE\" build --pull=false web" in deploy_script - assert "docker compose --env-file \"$ENV_FILE\" build --pull=false api" not in deploy_script - assert "docker compose --env-file \"$ENV_FILE\" build --pull=false worker" not in deploy_script + assert 'docker compose --env-file "$ENV_FILE" build --pull=false web' in deploy_script + assert 'docker compose --env-file "$ENV_FILE" build --pull=false api' not in deploy_script + assert 'docker compose --env-file "$ENV_FILE" build --pull=false worker' not in deploy_script def test_production_release_checklist_matches_automatic_release_contract(): @@ -306,9 +309,7 @@ def test_production_release_checklist_matches_automatic_release_contract(): def test_release_automation_retrospective_records_failed_probe_tags(): - retrospective = Path("docs/RELEASE-AUTOMATION-RETROSPECTIVE-2026-06-22.md").read_text( - encoding="utf-8" - ) + retrospective = Path("docs/RELEASE-AUTOMATION-RETROSPECTIVE-2026-06-22.md").read_text(encoding="utf-8") assert "v0.1.9" in retrospective assert "first verified end-to-end automatic production release" in retrospective diff --git a/tests/unit/test_worker_ingest_status.py b/tests/unit/test_worker_ingest_status.py index 501d353d9..2b8a82af4 100644 --- a/tests/unit/test_worker_ingest_status.py +++ b/tests/unit/test_worker_ingest_status.py @@ -5,5 +5,5 @@ def test_ingest_marks_created_assets_ready_with_size_metadata(): source = Path("apps/worker/worker_app/tasks/ingest.py").read_text(encoding="utf-8") assert "status=AssetStatus.READY" in source - assert "file_size=int(metadata[\"size_bytes\"])" in source - assert "duration=float(metadata[\"duration\"])" in source + assert 'file_size=int(metadata["size_bytes"])' in source + assert 'duration=float(metadata["duration"])' in source