Skip to content

Commit 411f906

Browse files
committed
feature: 新增小说封面图片生成功能
1 parent 2ca4c9c commit 411f906

22 files changed

Lines changed: 1504 additions & 193 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ backend/data/*.db-shm
7979
backend/data/*.db-wal
8080
backend/data/users.json
8181
backend/data/admins.json
82+
backend/storage/
8283

8384
# Temporary files
8485
*.bak
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""添加小说封面生成配置
2+
3+
Revision ID: 8e3ac0236b27
4+
Revises: d4d253e3f4c6
5+
Create Date: 2026-03-16 10:56:31.489936
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '8e3ac0236b27'
16+
down_revision: Union[str, None] = 'd4d253e3f4c6'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
# ### commands auto generated by Alembic - please adjust! ###
23+
op.add_column('projects', sa.Column('cover_image_url', sa.String(length=1000), nullable=True, comment='封面图片访问地址'))
24+
op.add_column('projects', sa.Column('cover_prompt', sa.Text(), nullable=True, comment='最近一次生成封面使用的提示词'))
25+
op.add_column('projects', sa.Column('cover_status', sa.String(length=20), nullable=False, server_default='none', comment='封面状态: none/generating/ready/failed'))
26+
op.add_column('projects', sa.Column('cover_error', sa.Text(), nullable=True, comment='最近一次封面生成失败原因'))
27+
op.add_column('projects', sa.Column('cover_updated_at', sa.DateTime(), nullable=True, comment='最近一次封面生成成功时间'))
28+
op.add_column('settings', sa.Column('cover_api_provider', sa.String(length=50), nullable=True, comment='封面图片API提供商'))
29+
op.add_column('settings', sa.Column('cover_api_key', sa.String(length=500), nullable=True, comment='封面图片API密钥'))
30+
op.add_column('settings', sa.Column('cover_api_base_url', sa.String(length=500), nullable=True, comment='封面图片自定义API地址'))
31+
op.add_column('settings', sa.Column('cover_image_model', sa.String(length=100), nullable=True, comment='封面图片模型名称'))
32+
op.add_column('settings', sa.Column('cover_enabled', sa.Boolean(), nullable=False, server_default=sa.text('false'), comment='是否启用封面图片生成'))
33+
# ### end Alembic commands ###
34+
35+
36+
def downgrade() -> None:
37+
# ### commands auto generated by Alembic - please adjust! ###
38+
op.drop_column('settings', 'cover_enabled')
39+
op.drop_column('settings', 'cover_image_model')
40+
op.drop_column('settings', 'cover_api_base_url')
41+
op.drop_column('settings', 'cover_api_key')
42+
op.drop_column('settings', 'cover_api_provider')
43+
op.drop_column('projects', 'cover_updated_at')
44+
op.drop_column('projects', 'cover_error')
45+
op.drop_column('projects', 'cover_status')
46+
op.drop_column('projects', 'cover_prompt')
47+
op.drop_column('projects', 'cover_image_url')
48+
# ### end Alembic commands ###
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""添加小说封面生成配置
2+
3+
Revision ID: 17ce752ed7cc
4+
Revises: d887fd1a30a6
5+
Create Date: 2026-03-16 10:58:55.143700
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '17ce752ed7cc'
16+
down_revision: Union[str, None] = 'd887fd1a30a6'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
# ### commands auto generated by Alembic - please adjust! ###
23+
with op.batch_alter_table('projects', schema=None) as batch_op:
24+
batch_op.add_column(sa.Column('cover_image_url', sa.String(length=1000), nullable=True, comment='封面图片访问地址'))
25+
batch_op.add_column(sa.Column('cover_prompt', sa.Text(), nullable=True, comment='最近一次生成封面使用的提示词'))
26+
batch_op.add_column(sa.Column('cover_status', sa.String(length=20), nullable=False, server_default='none', comment='封面状态: none/generating/ready/failed'))
27+
batch_op.add_column(sa.Column('cover_error', sa.Text(), nullable=True, comment='最近一次封面生成失败原因'))
28+
batch_op.add_column(sa.Column('cover_updated_at', sa.DateTime(), nullable=True, comment='最近一次封面生成成功时间'))
29+
30+
with op.batch_alter_table('settings', schema=None) as batch_op:
31+
batch_op.add_column(sa.Column('cover_api_provider', sa.String(length=50), nullable=True, comment='封面图片API提供商'))
32+
batch_op.add_column(sa.Column('cover_api_key', sa.String(length=500), nullable=True, comment='封面图片API密钥'))
33+
batch_op.add_column(sa.Column('cover_api_base_url', sa.String(length=500), nullable=True, comment='封面图片自定义API地址'))
34+
batch_op.add_column(sa.Column('cover_image_model', sa.String(length=100), nullable=True, comment='封面图片模型名称'))
35+
batch_op.add_column(sa.Column('cover_enabled', sa.Boolean(), nullable=False, server_default=sa.text('0'), comment='是否启用封面图片生成'))
36+
37+
# ### end Alembic commands ###
38+
39+
40+
def downgrade() -> None:
41+
# ### commands auto generated by Alembic - please adjust! ###
42+
with op.batch_alter_table('settings', schema=None) as batch_op:
43+
batch_op.drop_column('cover_enabled')
44+
batch_op.drop_column('cover_image_model')
45+
batch_op.drop_column('cover_api_base_url')
46+
batch_op.drop_column('cover_api_key')
47+
batch_op.drop_column('cover_api_provider')
48+
49+
with op.batch_alter_table('projects', schema=None) as batch_op:
50+
batch_op.drop_column('cover_updated_at')
51+
batch_op.drop_column('cover_error')
52+
batch_op.drop_column('cover_status')
53+
batch_op.drop_column('cover_prompt')
54+
batch_op.drop_column('cover_image_url')
55+
56+
# ### end Alembic commands ###

backend/app/api/project_covers.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""项目封面生成与下载 API"""
2+
from __future__ import annotations
3+
4+
from pydantic import BaseModel, Field
5+
from fastapi import APIRouter, Depends, HTTPException, Request
6+
from fastapi.responses import FileResponse
7+
from sqlalchemy.ext.asyncio import AsyncSession
8+
9+
from app.database import get_db
10+
from app.services.cover_generation_service import cover_generation_service
11+
12+
router = APIRouter(prefix="/projects", tags=["项目封面"])
13+
14+
15+
class CoverGenerateRequest(BaseModel):
16+
overwrite: bool = Field(default=True, description="是否覆盖已有封面")
17+
18+
19+
class CoverGenerateResponse(BaseModel):
20+
project_id: str
21+
cover_status: str
22+
cover_image_url: str | None = None
23+
cover_prompt: str | None = None
24+
provider: str | None = None
25+
model: str | None = None
26+
message: str
27+
28+
29+
@router.post("/{project_id}/cover/generate", response_model=CoverGenerateResponse, summary="生成项目封面")
30+
async def generate_project_cover(
31+
project_id: str,
32+
payload: CoverGenerateRequest,
33+
request: Request,
34+
db: AsyncSession = Depends(get_db),
35+
):
36+
user_id = getattr(request.state, "user_id", None)
37+
if not user_id:
38+
raise HTTPException(status_code=401, detail="未登录")
39+
40+
result = await cover_generation_service.generate_cover(
41+
db=db,
42+
user_id=user_id,
43+
project_id=project_id,
44+
overwrite=payload.overwrite,
45+
)
46+
return CoverGenerateResponse(**result)
47+
48+
49+
@router.get("/{project_id}/cover/download", summary="下载项目封面")
50+
async def download_project_cover(
51+
project_id: str,
52+
request: Request,
53+
db: AsyncSession = Depends(get_db),
54+
):
55+
user_id = getattr(request.state, "user_id", None)
56+
if not user_id:
57+
raise HTTPException(status_code=401, detail="未登录")
58+
59+
project, file_path = await cover_generation_service.get_cover_download_path(
60+
db=db,
61+
user_id=user_id,
62+
project_id=project_id,
63+
)
64+
suffix = file_path.suffix or ".png"
65+
filename = f"{project.title}-cover{suffix}"
66+
return FileResponse(path=file_path, filename=filename, media_type="application/octet-stream")

backend/app/api/settings.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from app.database import get_db
1616
from app.models.settings import Settings
17+
from app.services.cover_generation_service import cover_generation_service
1718
from app.schemas.settings import (
1819
SettingsCreate, SettingsUpdate, SettingsResponse,
1920
APIKeyPreset, APIKeyPresetConfig, PresetCreateRequest,
@@ -29,6 +30,13 @@
2930
router = APIRouter(prefix="/settings", tags=["设置管理"])
3031

3132

33+
class CoverSettingsTestRequest(BaseModel):
34+
cover_api_provider: str
35+
cover_api_key: str
36+
cover_api_base_url: Optional[str] = None
37+
cover_image_model: str
38+
39+
3240
def read_env_defaults() -> Dict[str, Any]:
3341
"""从.env文件读取默认配置(仅读取,不修改)"""
3442
return {
@@ -142,6 +150,25 @@ async def get_settings(
142150
return settings
143151

144152

153+
@router.post("/cover/test")
154+
async def test_cover_settings(
155+
data: CoverSettingsTestRequest,
156+
user: User = Depends(require_login),
157+
):
158+
result = await cover_generation_service.test_cover_settings(
159+
provider=data.cover_api_provider,
160+
api_key=data.cover_api_key,
161+
api_base_url=data.cover_api_base_url,
162+
model=data.cover_image_model,
163+
)
164+
return {
165+
"success": result.success,
166+
"message": result.message,
167+
"provider": result.provider,
168+
"model": result.model,
169+
}
170+
171+
145172
@router.post("", response_model=SettingsResponse)
146173
async def save_settings(
147174
data: SettingsCreate,

backend/app/main.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,8 @@ async def db_session_stats():
130130
wizard_stream, relationships, organizations,
131131
auth, users, settings, writing_styles, memories,
132132
mcp_plugins, admin, inspiration, prompt_templates,
133-
changelog, careers, foreshadows, prompt_workshop, book_import
133+
changelog, careers, foreshadows, prompt_workshop, book_import,
134+
project_covers
134135
)
135136

136137
app.include_router(auth.router, prefix="/api")
@@ -139,6 +140,7 @@ async def db_session_stats():
139140
app.include_router(admin.router, prefix="/api")
140141

141142
app.include_router(projects.router, prefix="/api")
143+
app.include_router(project_covers.router, prefix="/api")
142144
app.include_router(wizard_stream.router, prefix="/api")
143145
app.include_router(inspiration.router, prefix="/api")
144146
app.include_router(outlines.router, prefix="/api")
@@ -157,8 +159,12 @@ async def db_session_stats():
157159
app.include_router(book_import.router, prefix="/api") # 拆书导入API
158160

159161
static_dir = Path(__file__).parent.parent / "static"
162+
generated_assets_root_dir = Path(__file__).parent.parent / "storage"
163+
generated_covers_dir = generated_assets_root_dir / "generated_covers"
164+
generated_covers_dir.mkdir(parents=True, exist_ok=True)
160165
if static_dir.exists():
161166
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
167+
app.mount("/generated-assets/covers", StaticFiles(directory=str(generated_covers_dir)), name="generated-covers")
162168

163169
@app.get("/{full_path:path}")
164170
async def serve_spa(full_path: str):

backend/app/models/project.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ class Project(Base):
3232
chapter_count = Column(Integer, comment="章节数量")
3333
narrative_perspective = Column(String(50), comment="叙事视角:first_person/third_person/omniscient")
3434
character_count = Column(Integer, default=5, comment="角色数量")
35+
36+
# 封面字段
37+
cover_image_url = Column(String(1000), comment="封面图片访问地址")
38+
cover_prompt = Column(Text, comment="最近一次生成封面使用的提示词")
39+
cover_status = Column(String(20), default="none", nullable=False, comment="封面状态: none/generating/ready/failed")
40+
cover_error = Column(Text, comment="最近一次封面生成失败原因")
41+
cover_updated_at = Column(DateTime, comment="最近一次封面生成成功时间")
3542

3643
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
3744
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
@@ -41,7 +48,11 @@ class Project(Base):
4148
"outline_mode IN ('one-to-one', 'one-to-many')",
4249
name='check_outline_mode'
4350
),
51+
CheckConstraint(
52+
"cover_status IN ('none', 'generating', 'ready', 'failed')",
53+
name='check_cover_status'
54+
),
4455
)
4556

4657
def __repr__(self):
47-
return f"<Project(id={self.id}, title={self.title})>"
58+
return f"<Project(id={self.id}, title={self.title})>"

backend/app/models/settings.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""设置数据模型"""
2-
from sqlalchemy import Column, String, Text, Float, Integer, DateTime, Index
2+
from sqlalchemy import Column, String, Text, Float, Integer, DateTime, Boolean, Index
33
from sqlalchemy.sql import func
44
from app.database import Base
55
import uuid
@@ -18,6 +18,14 @@ class Settings(Base):
1818
temperature = Column(Float, default=0.7, comment="温度参数")
1919
max_tokens = Column(Integer, default=2000, comment="最大token数")
2020
system_prompt = Column(Text, comment="系统级别提示词,每次AI调用都会使用")
21+
22+
# 封面图片生成配置
23+
cover_api_provider = Column(String(50), comment="封面图片API提供商")
24+
cover_api_key = Column(String(500), comment="封面图片API密钥")
25+
cover_api_base_url = Column(String(500), comment="封面图片自定义API地址")
26+
cover_image_model = Column(String(100), comment="封面图片模型名称")
27+
cover_enabled = Column(Boolean, default=False, server_default="0", nullable=False, comment="是否启用封面图片生成")
28+
2129
preferences = Column(Text, comment="其他偏好设置(JSON)")
2230
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
2331
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
@@ -27,4 +35,4 @@ class Settings(Base):
2735
)
2836

2937
def __repr__(self):
30-
return f"<Settings(id={self.id}, user_id={self.user_id}, api_provider={self.api_provider})>"
38+
return f"<Settings(id={self.id}, user_id={self.user_id}, api_provider={self.api_provider})>"

backend/app/schemas/project.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ class ProjectResponse(ProjectBase):
5555
chapter_count: Optional[int] = None
5656
narrative_perspective: Optional[str] = None
5757
character_count: Optional[int] = None
58+
cover_image_url: Optional[str] = None
59+
cover_prompt: Optional[str] = None
60+
cover_status: Optional[str] = None
61+
cover_error: Optional[str] = None
62+
cover_updated_at: Optional[datetime] = None
5863
outline_mode: str # 显式声明以确保响应中包含
5964
created_at: datetime
6065
updated_at: datetime

backend/app/schemas/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ class SettingsBase(BaseModel):
1515
temperature: Optional[float] = Field(default=0.7, ge=0.0, le=2.0, description="温度参数")
1616
max_tokens: Optional[int] = Field(default=2000, ge=1, description="最大token数")
1717
system_prompt: Optional[str] = Field(default=None, description="系统级别提示词,每次AI调用都会使用")
18+
cover_api_provider: Optional[str] = Field(default=None, description="封面图片API提供商")
19+
cover_api_key: Optional[str] = Field(default=None, description="封面图片API密钥")
20+
cover_api_base_url: Optional[str] = Field(default=None, description="封面图片自定义API地址")
21+
cover_image_model: Optional[str] = Field(default=None, description="封面图片模型名称")
22+
cover_enabled: Optional[bool] = Field(default=False, description="是否启用封面图片生成")
1823
preferences: Optional[str] = Field(default=None, description="其他偏好设置(JSON)")
1924

2025

0 commit comments

Comments
 (0)