feat: M1 — 立项→写章草稿(SSE)→自动保存;连一家 provider
- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由 - 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本) - LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error) - API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接) - 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页 - M1 E2E:真实 DB + mock 网关零 token 走通闭环
This commit is contained in:
149
apps/api/ww_api/services/credentials.py
Normal file
149
apps/api/ww_api/services/credentials.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""凭据存储与提供商探测的接口 + SQLAlchemy/网关实现(ARCH §4.7)。
|
||||
|
||||
路由依赖这里的 **接口**(Protocol),测试注入内存替身;运行时用 SQLAlchemy/网关实现。
|
||||
单用户原型:`owner_id` 用固定 stub(见 `STUB_OWNER_ID`),多租户化时改为按认证主体取。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import ProviderCredential, TierRouting
|
||||
from ww_llm_gateway.adapters.base import Capabilities
|
||||
|
||||
# 单用户 stub owner(与网关 Scope.user_id 的 stub 约定一致:UUID(int=1))。
|
||||
# 多租户化时此常量由认证主体替换(见 ARCH §4.7 隔离)。
|
||||
STUB_OWNER_ID = uuid.UUID(int=1)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredCredential:
|
||||
"""存储层视图:含密文,绝不出 API 边界(路由仅取 provider 并掩码)。"""
|
||||
|
||||
provider: str
|
||||
api_key_enc: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredRouting:
|
||||
tier: str
|
||||
provider: str
|
||||
model: str
|
||||
fallback: list[str]
|
||||
|
||||
|
||||
class CredentialStore(Protocol):
|
||||
"""凭据 + 档位路由的读写接口(按 owner_id 隔离)。"""
|
||||
|
||||
async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]: ...
|
||||
|
||||
async def list_routing(self) -> list[StoredRouting]: ...
|
||||
|
||||
async def get_credential(
|
||||
self, owner_id: uuid.UUID, provider: str
|
||||
) -> StoredCredential | None: ...
|
||||
|
||||
async def upsert_credential(
|
||||
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
|
||||
) -> None: ...
|
||||
|
||||
async def upsert_routing(self, routing: StoredRouting) -> None: ...
|
||||
|
||||
|
||||
class ProviderProbe(Protocol):
|
||||
"""最小连通探测:验证 Key + 返回能力矩阵。测试注入假探测,绝不联网。"""
|
||||
|
||||
async def probe(self, owner_id: uuid.UUID, provider: str) -> Capabilities: ...
|
||||
|
||||
|
||||
class SqlCredentialStore:
|
||||
"""SQLAlchemy 实现:写 `provider_credentials` / `tier_routing`,幂等 upsert。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]:
|
||||
rows = (
|
||||
await self._session.execute(
|
||||
select(ProviderCredential).where(ProviderCredential.owner_id == owner_id)
|
||||
)
|
||||
).scalars()
|
||||
return [StoredCredential(provider=r.provider, api_key_enc=r.api_key_enc) for r in rows]
|
||||
|
||||
async def list_routing(self) -> list[StoredRouting]:
|
||||
rows = (await self._session.execute(select(TierRouting))).scalars()
|
||||
return [
|
||||
StoredRouting(
|
||||
tier=r.tier, provider=r.provider, model=r.model, fallback=list(r.fallback)
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
async def get_credential(self, owner_id: uuid.UUID, provider: str) -> StoredCredential | None:
|
||||
row = (
|
||||
await self._session.execute(
|
||||
select(ProviderCredential).where(
|
||||
ProviderCredential.owner_id == owner_id,
|
||||
ProviderCredential.provider == provider,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return StoredCredential(provider=row.provider, api_key_enc=row.api_key_enc)
|
||||
|
||||
async def upsert_credential(
|
||||
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
|
||||
) -> None:
|
||||
# 显式 read-modify-write:唯一约束含可空 project_id,PG ON CONFLICT
|
||||
# 在 NULL 上不去重(NULLS DISTINCT),故不用 on_conflict。
|
||||
existing = (
|
||||
await self._session.execute(
|
||||
select(ProviderCredential).where(
|
||||
ProviderCredential.owner_id == owner_id,
|
||||
ProviderCredential.project_id.is_(None),
|
||||
ProviderCredential.provider == provider,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
self._session.add(
|
||||
ProviderCredential(
|
||||
owner_id=owner_id,
|
||||
project_id=None,
|
||||
provider=provider,
|
||||
api_key_enc=api_key_enc,
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing.api_key_enc = api_key_enc
|
||||
await self._session.commit()
|
||||
|
||||
async def upsert_routing(self, routing: StoredRouting) -> None:
|
||||
existing = (
|
||||
await self._session.execute(
|
||||
select(TierRouting).where(
|
||||
TierRouting.project_id.is_(None),
|
||||
TierRouting.tier == routing.tier,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
self._session.add(
|
||||
TierRouting(
|
||||
project_id=None,
|
||||
tier=routing.tier,
|
||||
provider=routing.provider,
|
||||
model=routing.model,
|
||||
fallback=routing.fallback,
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing.provider = routing.provider
|
||||
existing.model = routing.model
|
||||
existing.fallback = routing.fallback
|
||||
await self._session.commit()
|
||||
162
apps/api/ww_api/services/project_deps.py
Normal file
162
apps/api/ww_api/services/project_deps.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""项目/章节端点的依赖装配(运行时实现)。
|
||||
|
||||
- `get_project_repo` / `get_chapter_repo`:把请求 session 装配成 SQLAlchemy repo。
|
||||
- `get_writer_gateway`:据 writer 档位路由从已存凭据解密 → 建 OpenAI 兼容适配器
|
||||
→ `Gateway`(注入 `SqlAlchemyLedgerSink` + `resolve_route`)。这是 **draft SSE 的可注入缝**——
|
||||
测试经 `app.dependency_overrides[get_writer_gateway]` 注入 mock 网关(产 `Delta`,绝不联网)。
|
||||
- `seed_stub_user`:幂等 seed 单用户 stub(owner_id FK 依赖它,见 memory/gotchas)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from openai import AsyncOpenAI
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
||||
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
|
||||
from ww_core.domain.repositories import MemoryRepos
|
||||
from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo
|
||||
from ww_core.memory.sql_repositories import sql_memory_repos
|
||||
from ww_db import get_session
|
||||
from ww_db.models import User
|
||||
from ww_llm_gateway import (
|
||||
Gateway,
|
||||
OpenAICompatAdapter,
|
||||
SqlAlchemyLedgerSink,
|
||||
resolve_route,
|
||||
)
|
||||
from ww_llm_gateway.types import Tier
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.security.credentials import (
|
||||
CredentialKeyError,
|
||||
decrypt_api_key,
|
||||
)
|
||||
from ww_api.services.credentials import (
|
||||
STUB_OWNER_ID,
|
||||
CredentialStore,
|
||||
SqlCredentialStore,
|
||||
)
|
||||
from ww_api.services.provider_deps import _PROVIDER_BASE_URLS
|
||||
|
||||
# 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。
|
||||
_STUB_USER_EMAIL = "stub@local"
|
||||
|
||||
|
||||
async def seed_stub_user(session: AsyncSession) -> None:
|
||||
"""幂等 seed 单用户 stub 行——所有 owner_id FK(projects/usage_ledger/...)依赖它。
|
||||
|
||||
无该行时插入;已存在则跳过。在 app lifespan 启动时调用一次。
|
||||
"""
|
||||
existing = (
|
||||
await session.execute(select(User).where(User.id == STUB_OWNER_ID))
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return
|
||||
session.add(User(id=STUB_OWNER_ID, email=_STUB_USER_EMAIL, display_name="stub"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
def get_project_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> ProjectRepo:
|
||||
return SqlProjectRepo(session)
|
||||
|
||||
|
||||
def get_chapter_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> ChapterRepo:
|
||||
return SqlChapterRepo(session)
|
||||
|
||||
|
||||
def get_memory_repos(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> MemoryRepos:
|
||||
"""记忆组装的 7-repo 捆绑(draft SSE 用)。测试覆盖此依赖注入内存 fake。"""
|
||||
return sql_memory_repos(session)
|
||||
|
||||
|
||||
def get_review_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> ReviewRepo:
|
||||
"""审稿留痕 repo(review SSE collect / 历史 / accept 裁决)。"""
|
||||
return SqlReviewRepo(session)
|
||||
|
||||
|
||||
def get_digest_append_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> DigestAppendRepo:
|
||||
"""章节摘要写侧 repo(验收事务追加终稿 digest)。"""
|
||||
return SqlDigestAppendRepo(session)
|
||||
|
||||
|
||||
async def build_gateway_for_tier(
|
||||
session: AsyncSession, store: CredentialStore, tier: Tier
|
||||
) -> Gateway:
|
||||
"""据指定档位路由解密对应 provider 凭据 → 建网关(解析器仍为全局 `resolve_route`)。
|
||||
|
||||
无凭据/未知 provider → `LLM_UNAVAILABLE`(友好提示,前端引导去配置)。
|
||||
解析器用 `resolve_route`(按 tier 路由);这里只决定**要预备哪个 provider 的适配器**。
|
||||
"""
|
||||
settings = get_settings()
|
||||
route = resolve_route(tier)
|
||||
base_url = _PROVIDER_BASE_URLS.get(route.provider)
|
||||
if base_url is None:
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
f"{tier} 档位 provider {route.provider} 暂不支持",
|
||||
{"provider": route.provider, "tier": tier},
|
||||
)
|
||||
cred = await store.get_credential(STUB_OWNER_ID, route.provider)
|
||||
if cred is None:
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置",
|
||||
{"provider": route.provider, "tier": tier},
|
||||
)
|
||||
try:
|
||||
api_key = decrypt_api_key(cred.api_key_enc, key=settings.credential_enc_key)
|
||||
except CredentialKeyError as exc:
|
||||
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
adapter = OpenAICompatAdapter(provider=route.provider, client=client)
|
||||
return Gateway(
|
||||
adapters={route.provider: adapter},
|
||||
ledger=SqlAlchemyLedgerSink(session),
|
||||
resolver=resolve_route,
|
||||
)
|
||||
|
||||
|
||||
async def build_writer_gateway(session: AsyncSession, store: CredentialStore) -> Gateway:
|
||||
"""据 writer 档位路由解密对应 provider 凭据 → 建网关。"""
|
||||
return await build_gateway_for_tier(session, store, "writer")
|
||||
|
||||
|
||||
async def get_writer_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""draft SSE 的可注入网关缝。测试覆盖此依赖注入 mock(产 `Delta`,绝不联网)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_writer_gateway(session, store)
|
||||
|
||||
|
||||
async def get_review_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""续审(analyst 档位)的可注入网关缝。测试经 override 注 mock(产 `parsed`,绝不联网)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
|
||||
async def get_digest_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""验收终稿 digest 提炼(light 档位)的可注入网关缝。测试经 override 注 mock。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "light")
|
||||
93
apps/api/ww_api/services/provider_deps.py
Normal file
93
apps/api/ww_api/services/provider_deps.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""提供商凭据端点的依赖装配(运行时实现)。
|
||||
|
||||
把 `get_session` 装配成 `SqlCredentialStore`;把网关适配器装配成探测器。
|
||||
测试经 `app.dependency_overrides` 注入内存替身——不联网。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from openai import AsyncOpenAI
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway.adapters.base import Capabilities
|
||||
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.security.credentials import (
|
||||
CredentialKeyError,
|
||||
decrypt_api_key,
|
||||
)
|
||||
from ww_api.services.credentials import (
|
||||
CredentialStore,
|
||||
SqlCredentialStore,
|
||||
)
|
||||
|
||||
# 已知 OpenAI 兼容提供商 → base_url(ARCH §4.2)。
|
||||
_PROVIDER_BASE_URLS: dict[str, str] = {
|
||||
"deepseek": "https://api.deepseek.com",
|
||||
"kimi": "https://api.moonshot.cn/v1",
|
||||
"qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"glm": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"openai": "https://api.openai.com/v1",
|
||||
}
|
||||
|
||||
|
||||
def get_credential_store(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> CredentialStore:
|
||||
return SqlCredentialStore(session)
|
||||
|
||||
|
||||
class GatewayProviderProbe:
|
||||
"""运行时探测:解密 Key→建 OpenAI 兼容适配器→最小请求验 Key→回能力矩阵。
|
||||
|
||||
依赖 store 取密文 + settings 取加密 key。绝不在日志/响应回显明文。
|
||||
"""
|
||||
|
||||
def __init__(self, store: CredentialStore, enc_key: str) -> None:
|
||||
self._store = store
|
||||
self._enc_key = enc_key
|
||||
|
||||
async def probe(self, owner_id: uuid.UUID, provider: str) -> Capabilities:
|
||||
cred = await self._store.get_credential(owner_id, provider)
|
||||
if cred is None:
|
||||
raise AppError(
|
||||
ErrorCode.NOT_FOUND,
|
||||
f"provider {provider} 未配置凭据",
|
||||
{"provider": provider},
|
||||
)
|
||||
base_url = _PROVIDER_BASE_URLS.get(provider)
|
||||
if base_url is None:
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"未知提供商 {provider}",
|
||||
{"provider": provider},
|
||||
)
|
||||
try:
|
||||
api_key = decrypt_api_key(cred.api_key_enc, key=self._enc_key)
|
||||
except CredentialKeyError as exc:
|
||||
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
adapter = OpenAICompatAdapter(provider=provider, client=client)
|
||||
try:
|
||||
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。
|
||||
await client.models.list()
|
||||
except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
f"provider {provider} 连接探测失败",
|
||||
{"provider": provider},
|
||||
) from exc
|
||||
return adapter.capabilities()
|
||||
|
||||
|
||||
def get_provider_probe(
|
||||
store: Annotated[CredentialStore, Depends(get_credential_store)],
|
||||
) -> GatewayProviderProbe:
|
||||
return GatewayProviderProbe(store, get_settings().credential_enc_key)
|
||||
Reference in New Issue
Block a user