Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""新增项目自动推进计划

Revision ID: 9f1b2c3d4e5f
Revises: 6eb27fce64de
Create Date: 2026-03-25 12:00:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '9f1b2c3d4e5f'
down_revision: Union[str, None] = '6eb27fce64de'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
'project_generation_schedules',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'),
sa.Column('user_id', sa.String(length=100), nullable=False, comment='用户ID'),
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.text('false'), comment='是否启用自动推进'),
sa.Column('cron_expr', sa.String(length=100), nullable=False, server_default='0 9 * * *', comment='Cron表达式'),
sa.Column('timezone', sa.String(length=100), nullable=False, server_default='Asia/Shanghai', comment='时区'),
sa.Column('chapters_per_run', sa.Integer(), nullable=False, server_default='1', comment='每次触发生成章节数'),
sa.Column('target_word_count', sa.Integer(), nullable=False, server_default='3000', comment='目标字数'),
sa.Column('enable_analysis', sa.Boolean(), nullable=False, server_default=sa.text('false'), comment='是否启用同步分析'),
sa.Column('enable_mcp', sa.Boolean(), nullable=False, server_default=sa.text('false'), comment='是否启用MCP增强'),
sa.Column('max_retries', sa.Integer(), nullable=False, server_default='3', comment='最大重试次数'),
sa.Column('model', sa.String(length=100), nullable=True, comment='指定使用的模型'),
sa.Column('min_ready_chapters', sa.Integer(), nullable=False, server_default='3', comment='最少待写章节缓冲数'),
sa.Column('outline_batch_size', sa.Integer(), nullable=False, server_default='1', comment='每次补充大纲数量'),
sa.Column('chapters_per_outline', sa.Integer(), nullable=False, server_default='3', comment='每个大纲默认展开章节数'),
sa.Column('expansion_strategy', sa.String(length=50), nullable=False, server_default='balanced', comment='展开策略'),
sa.Column('enable_scene_analysis', sa.Boolean(), nullable=False, server_default=sa.text('false'), comment='是否启用场景分析'),
sa.Column('last_pipeline_stage', sa.String(length=50), nullable=True, comment='最近一次执行到的流水线阶段'),
sa.Column('next_run_at', sa.DateTime(), nullable=True, comment='下一次执行时间'),
sa.Column('last_triggered_at', sa.DateTime(), nullable=True, comment='最近触发时间'),
sa.Column('last_finished_at', sa.DateTime(), nullable=True, comment='最近完成时间'),
sa.Column('last_run_status', sa.String(length=50), nullable=True, comment='最近运行状态'),
sa.Column('last_error', sa.Text(), nullable=True, comment='最近错误信息'),
sa.Column('current_batch_task_id', sa.String(length=36), nullable=True, comment='当前关联的批量任务ID'),
sa.Column('created_at', sa.DateTime(), nullable=True, server_default=sa.text('now()'), comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=True, server_default=sa.text('now()'), comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('project_id')
)
op.create_index(op.f('ix_project_generation_schedules_user_id'), 'project_generation_schedules', ['user_id'], unique=False)
op.create_index('idx_project_generation_schedules_enabled_next_run_at', 'project_generation_schedules', ['enabled', 'next_run_at'], unique=False)

op.add_column('batch_generation_tasks', sa.Column('enable_mcp', sa.Boolean(), nullable=False, server_default=sa.text('true'), comment='是否启用MCP工具增强'))
op.add_column('batch_generation_tasks', sa.Column('model_name', sa.String(length=100), nullable=True, comment='指定使用的AI模型'))
op.add_column('batch_generation_tasks', sa.Column('trigger_source', sa.String(length=30), nullable=False, server_default='manual', comment='任务触发来源: manual/schedule'))
op.add_column('batch_generation_tasks', sa.Column('schedule_id', sa.String(length=36), nullable=True, comment='关联的自动推进计划ID'))


def downgrade() -> None:
op.drop_column('batch_generation_tasks', 'schedule_id')
op.drop_column('batch_generation_tasks', 'trigger_source')
op.drop_column('batch_generation_tasks', 'model_name')
op.drop_column('batch_generation_tasks', 'enable_mcp')

op.drop_index('idx_project_generation_schedules_enabled_next_run_at', table_name='project_generation_schedules')
op.drop_index(op.f('ix_project_generation_schedules_user_id'), table_name='project_generation_schedules')
op.drop_table('project_generation_schedules')
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""新增项目自动推进计划

Revision ID: a1b2c3d4e5f6
Revises: 6ff45db05863
Create Date: 2026-03-25 12:01:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '6ff45db05863'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
'project_generation_schedules',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'),
sa.Column('user_id', sa.String(length=100), nullable=False, comment='用户ID'),
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.text('0'), comment='是否启用自动推进'),
sa.Column('cron_expr', sa.String(length=100), nullable=False, server_default='0 9 * * *', comment='Cron表达式'),
sa.Column('timezone', sa.String(length=100), nullable=False, server_default='Asia/Shanghai', comment='时区'),
sa.Column('chapters_per_run', sa.Integer(), nullable=False, server_default='1', comment='每次触发生成章节数'),
sa.Column('target_word_count', sa.Integer(), nullable=False, server_default='3000', comment='目标字数'),
sa.Column('enable_analysis', sa.Boolean(), nullable=False, server_default=sa.text('0'), comment='是否启用同步分析'),
sa.Column('enable_mcp', sa.Boolean(), nullable=False, server_default=sa.text('0'), comment='是否启用MCP增强'),
sa.Column('max_retries', sa.Integer(), nullable=False, server_default='3', comment='最大重试次数'),
sa.Column('model', sa.String(length=100), nullable=True, comment='指定使用的模型'),
sa.Column('min_ready_chapters', sa.Integer(), nullable=False, server_default='3', comment='最少待写章节缓冲数'),
sa.Column('outline_batch_size', sa.Integer(), nullable=False, server_default='1', comment='每次补充大纲数量'),
sa.Column('chapters_per_outline', sa.Integer(), nullable=False, server_default='3', comment='每个大纲默认展开章节数'),
sa.Column('expansion_strategy', sa.String(length=50), nullable=False, server_default='balanced', comment='展开策略'),
sa.Column('enable_scene_analysis', sa.Boolean(), nullable=False, server_default=sa.text('0'), comment='是否启用场景分析'),
sa.Column('last_pipeline_stage', sa.String(length=50), nullable=True, comment='最近一次执行到的流水线阶段'),
sa.Column('next_run_at', sa.DateTime(), nullable=True, comment='下一次执行时间'),
sa.Column('last_triggered_at', sa.DateTime(), nullable=True, comment='最近触发时间'),
sa.Column('last_finished_at', sa.DateTime(), nullable=True, comment='最近完成时间'),
sa.Column('last_run_status', sa.String(length=50), nullable=True, comment='最近运行状态'),
sa.Column('last_error', sa.Text(), nullable=True, comment='最近错误信息'),
sa.Column('current_batch_task_id', sa.String(length=36), nullable=True, comment='当前关联的批量任务ID'),
sa.Column('created_at', sa.DateTime(), nullable=True, server_default=sa.text('CURRENT_TIMESTAMP'), comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=True, server_default=sa.text('CURRENT_TIMESTAMP'), comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('project_id')
)
op.create_index('ix_project_generation_schedules_user_id', 'project_generation_schedules', ['user_id'], unique=False)
op.create_index('idx_project_generation_schedules_enabled_next_run_at', 'project_generation_schedules', ['enabled', 'next_run_at'], unique=False)

with op.batch_alter_table('batch_generation_tasks', schema=None) as batch_op:
batch_op.add_column(sa.Column('enable_mcp', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='是否启用MCP工具增强'))
batch_op.add_column(sa.Column('model_name', sa.String(length=100), nullable=True, comment='指定使用的AI模型'))
batch_op.add_column(sa.Column('trigger_source', sa.String(length=30), nullable=False, server_default='manual', comment='任务触发来源: manual/schedule'))
batch_op.add_column(sa.Column('schedule_id', sa.String(length=36), nullable=True, comment='关联的自动推进计划ID'))


def downgrade() -> None:
with op.batch_alter_table('batch_generation_tasks', schema=None) as batch_op:
batch_op.drop_column('schedule_id')
batch_op.drop_column('trigger_source')
batch_op.drop_column('model_name')
batch_op.drop_column('enable_mcp')

op.drop_index('idx_project_generation_schedules_enabled_next_run_at', table_name='project_generation_schedules')
op.drop_index('ix_project_generation_schedules_user_id', table_name='project_generation_schedules')
op.drop_table('project_generation_schedules')
148 changes: 148 additions & 0 deletions backend/app/api/project_generation_schedule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""项目自动推进计划 API"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import get_db
from app.models.project import Project
from app.models.project_generation_schedule import ProjectGenerationSchedule
from app.schemas.project_generation_schedule import (
ProjectGenerationScheduleResponse,
ProjectGenerationScheduleUpdate,
)
from app.services.project_generation_automation_service import project_generation_automation_service

router = APIRouter(prefix="/project-automation", tags=["项目自动推进"])


def _get_current_user_id(request: Request) -> str:
"""从请求上下文中获取当前用户 ID。"""
user_id = getattr(request.state, "user_id", None)
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
return user_id


async def _get_project(project_id: str, user_id: str, db: AsyncSession) -> Project:
"""获取当前用户有权访问的项目。"""
result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
return project


async def _get_schedule_by_project_id(
project_id: str,
user_id: str,
db: AsyncSession,
) -> ProjectGenerationSchedule | None:
"""按项目 ID 获取自动推进计划。"""
result = await db.execute(
select(ProjectGenerationSchedule).where(
ProjectGenerationSchedule.project_id == project_id,
ProjectGenerationSchedule.user_id == user_id,
)
)
return result.scalar_one_or_none()


def _serialize_schedule(
schedule: ProjectGenerationSchedule,
outline_mode: str,
) -> ProjectGenerationScheduleResponse:
"""序列化自动推进计划响应。"""
return ProjectGenerationScheduleResponse(
**{
**ProjectGenerationScheduleResponse.model_validate(schedule).model_dump(),
"outline_mode": outline_mode,
}
)


@router.get("/{project_id}", response_model=ProjectGenerationScheduleResponse, summary="获取项目自动推进计划")
async def get_project_generation_schedule(
project_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
) -> ProjectGenerationScheduleResponse:
user_id = _get_current_user_id(request)
project = await _get_project(project_id, user_id, db)
outline_mode = project.outline_mode
schedule = await _get_schedule_by_project_id(project_id, user_id, db)
if not schedule:
raise HTTPException(status_code=404, detail="自动推进计划不存在")
return _serialize_schedule(schedule, outline_mode)


@router.put("/{project_id}", response_model=ProjectGenerationScheduleResponse, summary="创建或更新项目自动推进计划")
async def save_project_generation_schedule(
project_id: str,
payload: ProjectGenerationScheduleUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
) -> ProjectGenerationScheduleResponse:
user_id = _get_current_user_id(request)
project = await _get_project(project_id, user_id, db)
outline_mode = project.outline_mode
schedule = await _get_schedule_by_project_id(project_id, user_id, db)

data = payload.model_dump()
model_name = (data.get("model") or "").strip()
data["model"] = model_name or None
try:
data["next_run_at"] = project_generation_automation_service.calculate_next_run_at(
data["cron_expr"], data["timezone"]
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

if schedule is None:
schedule = ProjectGenerationSchedule(project_id=project_id, user_id=user_id, **data)
db.add(schedule)
else:
for field, value in data.items():
setattr(schedule, field, value)

await db.commit()
await db.refresh(schedule)
return _serialize_schedule(schedule, outline_mode)


@router.post("/{project_id}/trigger", response_model=ProjectGenerationScheduleResponse, summary="立即执行一次项目自动推进")
async def trigger_project_generation_schedule(
project_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
) -> ProjectGenerationScheduleResponse:
user_id = _get_current_user_id(request)
project = await _get_project(project_id, user_id, db)
schedule = await _get_schedule_by_project_id(project_id, user_id, db)
if not schedule:
raise HTTPException(status_code=404, detail="自动推进计划不存在")

outline_mode = project.outline_mode
refreshed_schedule = await project_generation_automation_service.run_project_automation(schedule.id, db)
if refreshed_schedule is None:
raise HTTPException(status_code=404, detail="自动推进计划不存在")

return _serialize_schedule(refreshed_schedule, outline_mode)


@router.delete("/{project_id}", summary="删除项目自动推进计划")
async def delete_project_generation_schedule(
project_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
) -> dict[str, str]:
user_id = _get_current_user_id(request)
await _get_project(project_id, user_id, db)
schedule = await _get_schedule_by_project_id(project_id, user_id, db)
if not schedule:
raise HTTPException(status_code=404, detail="自动推进计划不存在")

await db.delete(schedule)
await db.commit()
return {"message": "项目自动推进计划已删除", "project_id": project_id}
4 changes: 2 additions & 2 deletions backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
Settings, WritingStyle, ProjectDefaultStyle,
RelationshipType, CharacterRelationship, Organization, OrganizationMember,
StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask,
RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate,
BackgroundTask, Announcement
ProjectGenerationSchedule, RegenerationTask, Career, CharacterCareer,
User, MCPPlugin, PromptTemplate, BackgroundTask, Announcement
)

# 引擎缓存:每个用户一个引擎
Expand Down
14 changes: 12 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
from app.middleware import RequestIDMiddleware
from app.middleware.auth_middleware import AuthMiddleware
from app.mcp import mcp_client, register_status_sync
from app.services.project_generation_scheduler import (
start_project_generation_scheduler,
stop_project_generation_scheduler,
)

setup_logging(
level=config_settings.log_level,
Expand Down Expand Up @@ -44,9 +48,13 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"后台任务表检查失败(不影响启动): {e}")

start_project_generation_scheduler()

logger.info("应用启动完成")

yield

await stop_project_generation_scheduler()

# 清理MCP插件
await mcp_client.cleanup()
Expand Down Expand Up @@ -147,7 +155,8 @@ async def db_session_stats(request: Request):
auth, users, settings, writing_styles, memories,
mcp_plugins, admin, inspiration, prompt_templates,
changelog, careers, foreshadows, prompt_workshop, book_import,
project_covers, tasks, skills, announcements
project_covers, tasks, skills, announcements,
project_generation_schedule
)

app.include_router(auth.router, prefix="/api")
Expand All @@ -157,6 +166,7 @@ async def db_session_stats(request: Request):

app.include_router(projects.router, prefix="/api")
app.include_router(project_covers.router, prefix="/api")
app.include_router(project_generation_schedule.router, prefix="/api")
app.include_router(wizard_stream.router, prefix="/api")
app.include_router(inspiration.router, prefix="/api")
app.include_router(outlines.router, prefix="/api")
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from app.models.generation_history import GenerationHistory
from app.models.analysis_task import AnalysisTask
from app.models.batch_generation_task import BatchGenerationTask
from app.models.project_generation_schedule import ProjectGenerationSchedule
from app.models.settings import Settings
from app.models.memory import StoryMemory, PlotAnalysis
from app.models.writing_style import WritingStyle
Expand All @@ -33,6 +34,7 @@
"GenerationHistory",
"AnalysisTask",
"BatchGenerationTask",
"ProjectGenerationSchedule",
"Settings",
"StoryMemory",
"PlotAnalysis",
Expand Down
Loading