✨ feat: WP-08 媒体基础与 CI(上传/文件/任务/声契 + 三任务流水线)

- 迁移 15:upload(三态)、file_asset(sha256/存储键/pending-ready-quarantined)、media_job(四类五态+进度+取消)、voice_consent(用途/期限/撤回)
- 上传三步流(契约第 9 节开发档):POST uploads(MIME 白名单+大小约束)→ PUT data(真实写 storage/ 落盘、sha256 校验)→ completions 转 ready——未完成文件不可被业务引用
- 文件内容取回(开发档本地;正式部署换短期签名 URL,存储键不变)
- 媒体任务:ASR 输入必须 ready 音频;后台执行经注入 Provider(测试 mock 确定性转写);无 Provider 显式失败(fail-closed);取消幂等
- 声契:TTS/克隆需有效声契(未撤回未过期);撤回后新任务 403(ADR 媒体域“撤回停止新生成”)
- CI(WP-14 起步):三任务流水线——backend(pgvector 服务容器 + ruff/mypy/pytest)、engine(CMake 警告即错误 + ctest)、openapi(Redocly lint)
- 测试 91 项全绿(+5)
This commit is contained in:
chenxin committed 2026-09-12 10:50:37 +08:00
1 parent 9ef2568c3b
commit 701c20c783
7 files changed
+400

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ class Settings(BaseSettings):
log_level: str = "info"
api_port: int = 8000
database_url: str = "postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing"
storage_root: str = "storage"
@lru_cache
+2
View File
@@ -13,6 +13,7 @@ from app.core.middleware import RequestContextMiddleware, configure_logging
from app.db.session import init_engine
from app.marketplace.routes import finance_router, webhook_router
from app.marketplace.routes import router as marketplace_router
from app.media.routes import router as media_router
from app.memories.routes import cycle_router
from app.memories.routes import router as memories_router
from app.plugins.routes import inst_router, invoke_router
@@ -51,6 +52,7 @@ def create_app() -> FastAPI:
app.include_router(plugins_router)
app.include_router(inst_router)
app.include_router(invoke_router)
app.include_router(media_router)
@app.get("/healthz", tags=["ops"])
async def healthz() -> dict[str, str]:
+104
View File
@@ -1172,3 +1172,107 @@ class PluginInstallation(Base):
__table_args__ = (
Index("uq_plugin_installation", "domain_id", "plugin_name", unique=True),
)
class Upload(Base):
"""上传会话:登记约束 → 直传对象存储 → 完成校验(契约第 9 节)。"""
__tablename__ = "upload"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("upload_")
)
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
filename: Mapped[str] = mapped_column(String, nullable=False)
media_type: Mapped[str] = mapped_column(String, nullable=False)
size_limit: Mapped[int] = mapped_column(nullable=False, default=100 * 1024 * 1024)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint("status IN ('pending', 'completed', 'failed')", name="status_values"),
)
class FileAsset(Base):
"""文件资产:未完成/隔离文件不可被业务引用(契约第 9 节)。"""
__tablename__ = "file_asset"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("file_"))
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
upload_id: Mapped[str | None] = mapped_column(
ForeignKey("upload.id", ondelete="set null"), nullable=True
)
filename: Mapped[str] = mapped_column(String, nullable=False)
media_type: Mapped[str] = mapped_column(String, nullable=False)
size_bytes: Mapped[int] = mapped_column(nullable=False, default=0)
sha256: Mapped[str | None] = mapped_column(String, nullable=True)
storage_key: Mapped[str] = mapped_column(String, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'ready', 'failed', 'quarantined')", name="status_values"
),
)
class MediaJob(Base):
"""媒体任务:ASR/TTS/图像等;无可用 Provider 时失败(fail-closed)。"""
__tablename__ = "media_job"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("media_"))
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
kind: Mapped[str] = mapped_column(String, nullable=False)
input: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
output: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
status: Mapped[str] = mapped_column(String, nullable=False, default="queued")
progress: Mapped[int] = mapped_column(nullable=False, default=0)
error: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint("kind IN ('asr', 'tts', 'image_gen', 'image_edit')", name="kind_values"),
CheckConstraint(
"status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')",
name="status_values",
),
)
class VoiceConsent(Base):
"""声契:声音授权(身份/用途/期限),撤回后停止新生成(契约媒体域)。"""
__tablename__ = "voice_consent"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("voice_consent_")
)
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
character_id: Mapped[str | None] = mapped_column(
ForeignKey("character.id", ondelete="cascade"), nullable=True
)
purpose: Mapped[str] = mapped_column(String, nullable=False, default="voice_clone")
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)