feat(api): vertical slice POST /v1/agent/chat and the Celery ping path
One request exercises every layer: JWT auth, quota reservation, a versioned prompt through the LLM gateway, a cost row, quota commit or refund, and the unified envelope. The trivial ping task proves the queue routing and the tk_task transitions before any real long-running task is written. Routes are mounted at the /api/v1 base path of 架构文档 §9.1; /v1/* is rewritten to it at the ASGI layer so both spellings work from one route table.
This commit is contained in:
Executable
+33
@@ -0,0 +1,33 @@
|
||||
# `apps/` — 可部署的进程
|
||||
|
||||
| 目录 | 进程 |
|
||||
| --- | --- |
|
||||
| `api/` | FastAPI 应用:路由、依赖注入、中间件 |
|
||||
| `worker/` | Celery 任务定义与四队列配置(gpu / cpu / io / beat) |
|
||||
| `admin/` | 极简运营后台(占位,FR-ADM-01~05) |
|
||||
|
||||
## API 层的职责边界(架构 §3.1 L3)
|
||||
|
||||
**只做编排**:认证鉴权、参数校验、幂等、配额校验与预扣、统一响应、OpenAPI。
|
||||
|
||||
## 不能放什么
|
||||
|
||||
- **业务规则**。路由函数里出现 `if` 判断业务条件,几乎总是应该下沉到
|
||||
`packages/<域>/service.py` 或 `packages/domain/`。
|
||||
- **直接写 SQL 或用 ORM 查表**。走域的 `service`。
|
||||
- **直接调用外部 API**。LLM 走 `packages/llm`;未来的审核、视觉、换装同理。
|
||||
- 裸 `dict` 返回。所有响应必须经过 `packages.shared.response.ok()` / `fail()`。
|
||||
|
||||
## 契约先行(NFR-MAINT-01)
|
||||
|
||||
先在 `apps/api/schemas.py` 定义 Pydantic schema,再写实现。客户端类型由
|
||||
OpenAPI 生成,schema 一变客户端立刻可见。
|
||||
|
||||
## Worker 的规矩
|
||||
|
||||
- 每个长耗时任务都必须落 `tk_task`,走 `packages/task` 的状态机。
|
||||
不要自己维护状态字段。
|
||||
- 失败必须退还配额(`billing.refund()`,FR-BILL-05)。
|
||||
`apps/worker/tasks.py` 里的 `ping_task` 是形状示例。
|
||||
- 队列按资源画像分:GPU 推理进 `gpu`,视觉/渲染进 `cpu`,外部调用进 `io`。
|
||||
混队列会让「换装排队把报告拖慢」。
|
||||
@@ -0,0 +1 @@
|
||||
"""可部署进程:API / Celery worker / 运营后台。"""
|
||||
@@ -0,0 +1 @@
|
||||
"""极简运营后台(占位,FR-ADM-01~05)。"""
|
||||
@@ -0,0 +1 @@
|
||||
"""FastAPI 应用(架构文档 §3.1 L3 接入编排层)。"""
|
||||
Executable
+172
@@ -0,0 +1,172 @@
|
||||
"""Composition root + FastAPI dependencies.
|
||||
|
||||
This is the only place that wires concrete adapters into the domain packages.
|
||||
Everything below it takes its collaborators as constructor arguments, which is
|
||||
what keeps ``packages/domain`` and ``packages/llm`` unit-testable without IO.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from decimal import Decimal
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.agent.service import AgentService
|
||||
from packages.billing.service import BillingService
|
||||
from packages.identity.service import IdentityService
|
||||
from packages.llm.budget import DailyBudgetGuard, RedisCostCounter
|
||||
from packages.llm.circuit import CircuitBreakerRegistry
|
||||
from packages.llm.cost import CompositeAICallRecorder, ModelRate, RateTable
|
||||
from packages.llm.gateway import LLMGateway
|
||||
from packages.llm.prompts import PromptRegistry
|
||||
from packages.llm.providers.base import LLMProvider
|
||||
from packages.llm.providers.deepseek import DeepSeekProvider
|
||||
from packages.llm.providers.zhipu import ZhipuGLMProvider
|
||||
from packages.llm.recording import PrometheusAICallRecorder, SqlAlchemyAICallRecorder
|
||||
from packages.llm.routing import SceneRouter, build_scene_configs
|
||||
from packages.shared.config import Settings, get_settings
|
||||
from packages.shared.context import bind_context
|
||||
from packages.shared.db import get_session_factory
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.redis_client import get_redis
|
||||
from packages.shared.security import decode_access_token
|
||||
from packages.task.service import TaskService
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def build_llm_gateway(settings: Settings) -> LLMGateway:
|
||||
"""Assemble the gateway from configuration (架构文档 §8.1)."""
|
||||
providers: dict[str, LLMProvider] = {
|
||||
"deepseek": DeepSeekProvider(
|
||||
api_key=settings.deepseek_api_key,
|
||||
base_url=settings.deepseek_base_url,
|
||||
default_model=settings.deepseek_model,
|
||||
),
|
||||
"zhipu": ZhipuGLMProvider(
|
||||
api_key=settings.zhipu_api_key,
|
||||
base_url=settings.zhipu_base_url,
|
||||
default_model=settings.zhipu_model,
|
||||
),
|
||||
}
|
||||
|
||||
router = SceneRouter(
|
||||
build_scene_configs(
|
||||
primary_provider=settings.llm_primary_provider,
|
||||
backup_provider=settings.llm_backup_provider,
|
||||
structured_max_retries=settings.llm_structured_max_retries,
|
||||
overrides=settings.llm_scene_overrides,
|
||||
)
|
||||
)
|
||||
|
||||
rates = RateTable(
|
||||
{model: ModelRate.from_mapping(rate) for model, rate in settings.llm_cost_rates.items()},
|
||||
default=ModelRate.from_mapping(settings.llm_default_cost_rate),
|
||||
)
|
||||
|
||||
recorder = CompositeAICallRecorder(
|
||||
SqlAlchemyAICallRecorder(get_session_factory()),
|
||||
PrometheusAICallRecorder(),
|
||||
)
|
||||
|
||||
budget = DailyBudgetGuard(
|
||||
RedisCostCounter(get_redis()),
|
||||
daily_budget_cents=Decimal(settings.llm_daily_budget_cents),
|
||||
enabled=settings.llm_daily_budget_enabled,
|
||||
tz_offset_hours=settings.llm_budget_timezone_offset_hours,
|
||||
)
|
||||
|
||||
return LLMGateway(
|
||||
providers=providers,
|
||||
router=router,
|
||||
prompts=PromptRegistry(
|
||||
settings.prompts_dir, cache_prefix_mode=settings.llm_cache_prefix_mode
|
||||
),
|
||||
rates=rates,
|
||||
recorder=recorder,
|
||||
budget=budget,
|
||||
breakers=CircuitBreakerRegistry(
|
||||
failure_threshold=settings.llm_circuit_failure_threshold,
|
||||
cooldown_seconds=settings.llm_circuit_cooldown_seconds,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# --- request-scoped dependencies -------------------------------------------
|
||||
|
||||
|
||||
def settings_dep() -> Settings:
|
||||
return get_settings()
|
||||
|
||||
|
||||
async def session_dep() -> AsyncIterator[AsyncSession]:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def gateway_dep(request: Request) -> LLMGateway:
|
||||
gateway = getattr(request.app.state, "llm_gateway", None)
|
||||
if gateway is None: # pragma: no cover - startup guarantees this
|
||||
raise AppError(ErrorCode.CONFIG_ERROR, message="LLM 网关未初始化")
|
||||
return gateway
|
||||
|
||||
|
||||
SettingsDep = Annotated[Settings, Depends(settings_dep)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(session_dep)]
|
||||
GatewayDep = Annotated[LLMGateway, Depends(gateway_dep)]
|
||||
|
||||
|
||||
async def current_user_id(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
|
||||
settings: SettingsDep,
|
||||
) -> int:
|
||||
"""架构文档 §9.1: ``Authorization: Bearer <access_token>``."""
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise AppError(ErrorCode.UNAUTHENTICATED)
|
||||
claims = decode_access_token(
|
||||
credentials.credentials,
|
||||
secret=settings.jwt_secret,
|
||||
algorithm=settings.jwt_algorithm,
|
||||
issuer=settings.jwt_issuer,
|
||||
)
|
||||
bind_context(user_id=claims.user_id)
|
||||
return claims.user_id
|
||||
|
||||
|
||||
CurrentUserId = Annotated[int, Depends(current_user_id)]
|
||||
|
||||
|
||||
def identity_service_dep(session: SessionDep) -> IdentityService:
|
||||
return IdentityService(session)
|
||||
|
||||
|
||||
def billing_service_dep(session: SessionDep, settings: SettingsDep) -> BillingService:
|
||||
return BillingService(session, tz_offset_hours=settings.llm_budget_timezone_offset_hours)
|
||||
|
||||
|
||||
def task_service_dep(session: SessionDep) -> TaskService:
|
||||
return TaskService(session)
|
||||
|
||||
|
||||
def agent_service_dep(
|
||||
session: SessionDep,
|
||||
gateway: GatewayDep,
|
||||
billing: Annotated[BillingService, Depends(billing_service_dep)],
|
||||
) -> AgentService:
|
||||
return AgentService(session, gateway=gateway, billing=billing)
|
||||
|
||||
|
||||
IdentityServiceDep = Annotated[IdentityService, Depends(identity_service_dep)]
|
||||
BillingServiceDep = Annotated[BillingService, Depends(billing_service_dep)]
|
||||
TaskServiceDep = Annotated[TaskService, Depends(task_service_dep)]
|
||||
AgentServiceDep = Annotated[AgentService, Depends(agent_service_dep)]
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
"""FastAPI application factory (架构文档 §3.1 L3 API 层).
|
||||
|
||||
The API layer only orchestrates: auth, validation, idempotency, quota,
|
||||
unified response, OpenAPI. Business rules live in ``packages/<domain>``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from apps.api.deps import build_llm_gateway
|
||||
from apps.api.routers import agent, auth, health, quota, tasks
|
||||
from packages.shared.config import Settings, get_settings
|
||||
from packages.shared.db import dispose_engine
|
||||
from packages.shared.handlers import install_exception_handlers
|
||||
from packages.shared.logging import configure_logging, get_logger
|
||||
from packages.shared.middleware import RequestContextMiddleware, install_version_alias
|
||||
from packages.shared.observability import setup_tracing
|
||||
from packages.shared.redis_client import close_redis
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
settings: Settings = get_settings()
|
||||
app.state.llm_gateway = build_llm_gateway(settings)
|
||||
logger.info(
|
||||
"api_started",
|
||||
env=settings.app_env,
|
||||
api_prefix=settings.api_prefix,
|
||||
llm_primary=settings.llm_primary_provider,
|
||||
llm_model=settings.deepseek_model,
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await app.state.llm_gateway.aclose()
|
||||
await close_redis()
|
||||
await dispose_engine()
|
||||
logger.info("api_stopped")
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
cfg = settings or get_settings()
|
||||
configure_logging(
|
||||
level=cfg.log_level, json_output=cfg.log_json, extra_redact_keys=cfg.extra_redact_keys
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title="美搭 backend",
|
||||
version="0.1.0",
|
||||
description=(
|
||||
"AI 穿搭美学助手服务端。统一响应见架构文档 §9.2;"
|
||||
"所有业务接口在 " + cfg.api_prefix + " 之下(/v1 为等价别名)。"
|
||||
),
|
||||
openapi_url=f"{cfg.api_prefix}/openapi.json",
|
||||
docs_url=f"{cfg.api_prefix}/docs" if cfg.is_local else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
install_version_alias(app, cfg.api_prefix)
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
install_exception_handlers(app)
|
||||
setup_tracing(app, cfg)
|
||||
|
||||
app.include_router(health.router)
|
||||
for router in (auth.router, agent.router, quota.router, tasks.router):
|
||||
app.include_router(router, prefix=cfg.api_prefix)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP 路由。只做编排,业务规则下沉到 packages/。"""
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
"""``POST /agent/chat`` — the vertical slice.
|
||||
|
||||
One request exercises: JWT auth → quota reserve → LLM gateway (routing, prompt
|
||||
version, cache-prefix check, daily budget) → ``ob_ai_call`` cost row → quota
|
||||
commit/refund → unified envelope. Streaming (SSE, FR-AGT-01) is intentionally
|
||||
out of scope for the scaffold.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from apps.api.deps import AgentServiceDep, CurrentUserId
|
||||
from apps.api.schemas import ChatRequest, ChatResponse, ChatUsage
|
||||
from packages.llm.errors import (
|
||||
AllProvidersFailedError,
|
||||
CachePrefixViolationError,
|
||||
DailyBudgetExceededError,
|
||||
StructuredOutputError,
|
||||
)
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.response import ok
|
||||
|
||||
router = APIRouter(prefix="/agent", tags=["agent"])
|
||||
|
||||
|
||||
@router.post("/chat", summary="Single-turn styling chat (non-streaming)")
|
||||
async def chat(
|
||||
payload: ChatRequest,
|
||||
user_id: CurrentUserId,
|
||||
agent: AgentServiceDep,
|
||||
) -> dict[str, object]:
|
||||
request_key = payload.request_key or uuid.uuid4().hex
|
||||
try:
|
||||
result = await agent.chat(user_id=user_id, message=payload.message, request_key=request_key)
|
||||
except DailyBudgetExceededError as exc:
|
||||
raise AppError(ErrorCode.LLM_DAILY_BUDGET_EXCEEDED) from exc
|
||||
except StructuredOutputError as exc:
|
||||
raise AppError(ErrorCode.LLM_STRUCTURED_OUTPUT_FAILED) from exc
|
||||
except AllProvidersFailedError as exc:
|
||||
raise AppError(ErrorCode.LLM_ALL_PROVIDERS_FAILED) from exc
|
||||
except CachePrefixViolationError as exc:
|
||||
raise AppError(ErrorCode.LLM_PROMPT_CACHE_VIOLATION) from exc
|
||||
|
||||
return ok(
|
||||
ChatResponse(
|
||||
reply=result.reply,
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
prompt_id=result.prompt_id,
|
||||
prompt_version=result.prompt_version,
|
||||
call_id=str(result.call_id),
|
||||
usage=ChatUsage(
|
||||
tokens_in=result.tokens_in,
|
||||
tokens_out=result.tokens_out,
|
||||
tokens_cached=result.tokens_cached,
|
||||
latency_ms=result.latency_ms,
|
||||
cost_cents=str(result.cost_cents),
|
||||
),
|
||||
quota_remaining=result.quota_remaining,
|
||||
).model_dump()
|
||||
)
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
"""Development-only token issuance.
|
||||
|
||||
**This is not the V1 auth design.** FR-ACC-01/04/05 require 华为账号登录、手机号
|
||||
验证码、双令牌与服务端可撤销的 refresh token. This endpoint exists so the
|
||||
vertical slice can be exercised end to end, and it refuses to run unless
|
||||
``DEV_AUTH_ENABLED`` is true and the environment is local/dev.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from apps.api.deps import IdentityServiceDep, SettingsDep
|
||||
from apps.api.schemas import DevTokenRequest, DevTokenResponse
|
||||
from packages.identity.service import DEV_PROVIDER
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.response import ok
|
||||
from packages.shared.security import create_access_token
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/dev-token", summary="[dev only] Issue an access token for testing")
|
||||
async def dev_token(
|
||||
payload: DevTokenRequest,
|
||||
settings: SettingsDep,
|
||||
identity: IdentityServiceDep,
|
||||
) -> dict[str, object]:
|
||||
if not (settings.dev_auth_enabled and settings.is_local):
|
||||
raise AppError(ErrorCode.NOT_FOUND)
|
||||
|
||||
user = await identity.get_or_create_by_external_identity(
|
||||
provider=DEV_PROVIDER,
|
||||
union_id=payload.device_key,
|
||||
register_source="dev",
|
||||
)
|
||||
token = create_access_token(
|
||||
user_id=user.user_id,
|
||||
secret=settings.jwt_secret,
|
||||
algorithm=settings.jwt_algorithm,
|
||||
ttl_seconds=settings.jwt_access_ttl_seconds,
|
||||
issuer=settings.jwt_issuer,
|
||||
)
|
||||
return ok(
|
||||
DevTokenResponse(
|
||||
access_token=token,
|
||||
expires_in=settings.jwt_access_ttl_seconds,
|
||||
user_id=str(user.user_id),
|
||||
).model_dump()
|
||||
)
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
"""Liveness, readiness and metrics. Mounted at the root, outside ``/api/v1``.
|
||||
|
||||
``/healthz`` must never touch a dependency: if it did, a slow database would
|
||||
make the orchestrator kill healthy processes. ``/readyz`` is the one that checks
|
||||
dependencies, because a process that cannot reach PostgreSQL should be taken out
|
||||
of the load-balancer rotation but not restarted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Response
|
||||
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
||||
from sqlalchemy import text
|
||||
|
||||
from packages.shared.config import get_settings
|
||||
from packages.shared.db import get_session_factory
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.logging import get_logger
|
||||
from packages.shared.observability import REGISTRY
|
||||
from packages.shared.redis_client import get_redis
|
||||
from packages.shared.response import ok
|
||||
|
||||
router = APIRouter(tags=["ops"])
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("/healthz", summary="Liveness probe")
|
||||
async def healthz() -> dict[str, object]:
|
||||
settings = get_settings()
|
||||
return ok({"status": "ok", "app": settings.app_name, "env": settings.app_env})
|
||||
|
||||
|
||||
@router.get("/readyz", summary="Readiness probe (checks PostgreSQL and Redis)")
|
||||
async def readyz() -> dict[str, object]:
|
||||
checks: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
checks["postgres"] = "ok"
|
||||
except Exception as exc:
|
||||
checks["postgres"] = f"error: {type(exc).__name__}"
|
||||
|
||||
try:
|
||||
await get_redis().ping()
|
||||
checks["redis"] = "ok"
|
||||
except Exception as exc:
|
||||
checks["redis"] = f"error: {type(exc).__name__}"
|
||||
|
||||
if any(v != "ok" for v in checks.values()):
|
||||
logger.warning("readiness_failed", checks=checks)
|
||||
raise AppError(ErrorCode.UPSTREAM_ERROR, message="依赖未就绪", data={"checks": checks})
|
||||
return ok({"status": "ready", "checks": checks})
|
||||
|
||||
|
||||
@router.get("/metrics", summary="Prometheus scrape endpoint", include_in_schema=False)
|
||||
async def metrics() -> Response:
|
||||
return Response(content=generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
"""``GET /quota`` and ``GET /quota/ledger`` (架构文档 §9.3 配额).
|
||||
|
||||
FR-BILL-07: 额度相关文案中**不得出现任何价格、购买、会员、升级字样**。
|
||||
Only two recovery paths may ever be surfaced: 等待恢复 and 做任务换额度.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from apps.api.deps import BillingServiceDep, CurrentUserId
|
||||
from apps.api.schemas import LedgerEntry, QuotaResponse
|
||||
from packages.agent.service import QUOTA_KEY_AGENT_CHAT
|
||||
from packages.shared.response import ok
|
||||
|
||||
router = APIRouter(prefix="/quota", tags=["quota"])
|
||||
|
||||
|
||||
@router.get("", summary="Current period quota for a feature")
|
||||
async def get_quota(
|
||||
user_id: CurrentUserId,
|
||||
billing: BillingServiceDep,
|
||||
quota_key: str = Query(default=QUOTA_KEY_AGENT_CHAT, max_length=64),
|
||||
) -> dict[str, object]:
|
||||
snapshot = await billing.get_quota(user_id=user_id, quota_key=quota_key)
|
||||
return ok(
|
||||
QuotaResponse(
|
||||
quota_key=snapshot.quota_key,
|
||||
period_key=snapshot.period_key,
|
||||
used=snapshot.used,
|
||||
limit_value=snapshot.limit_value,
|
||||
bonus_value=snapshot.bonus_value,
|
||||
remaining=snapshot.remaining,
|
||||
reset_at=snapshot.reset_at,
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ledger", summary="Quota ledger — 流水是真相,余额是缓存")
|
||||
async def get_ledger(
|
||||
user_id: CurrentUserId,
|
||||
billing: BillingServiceDep,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> dict[str, object]:
|
||||
rows = await billing.list_ledger(user_id=user_id, limit=limit)
|
||||
return ok(
|
||||
[
|
||||
LedgerEntry(
|
||||
tx_id=str(row.tx_id),
|
||||
delta=row.delta,
|
||||
tx_type=row.tx_type,
|
||||
status=row.status,
|
||||
reason=row.reason,
|
||||
quota_key=row.quota_key,
|
||||
period_key=row.period_key,
|
||||
balance_after=row.balance_after,
|
||||
created_at=row.created_at,
|
||||
).model_dump()
|
||||
for row in rows
|
||||
]
|
||||
)
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
"""任务中心 endpoints + a trivial async task that proves the Celery path.
|
||||
|
||||
The ``ping`` task carries no business value; it exists so that the ``tk_task``
|
||||
state machine, the queue routing and the worker deployment are all exercised
|
||||
before any real long-running task is written (M1 出口条件).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from apps.api.deps import CurrentUserId, TaskServiceDep
|
||||
from apps.api.schemas import TaskResponse, TaskSubmitRequest
|
||||
from packages.domain.task.state import TaskStatus, TaskType
|
||||
from packages.shared.models import Task
|
||||
from packages.shared.response import ok
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
def _serialize(task: Task) -> dict[str, object]:
|
||||
return TaskResponse(
|
||||
task_id=str(task.task_id),
|
||||
task_type=task.task_type,
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
stage_text=task.stage_text,
|
||||
result=task.result,
|
||||
error_code=task.error_code,
|
||||
error_message=task.error_message,
|
||||
retry_count=task.retry_count,
|
||||
submitted_at=task.submitted_at,
|
||||
started_at=task.started_at,
|
||||
finished_at=task.finished_at,
|
||||
).model_dump()
|
||||
|
||||
|
||||
@router.post("/ping", summary="Submit the scaffold's trivial async task")
|
||||
async def submit_ping(
|
||||
payload: TaskSubmitRequest,
|
||||
user_id: CurrentUserId,
|
||||
tasks: TaskServiceDep,
|
||||
) -> dict[str, object]:
|
||||
from apps.worker.tasks import ping_task
|
||||
|
||||
idempotency_key = payload.idempotency_key or f"ping:{user_id}:{uuid.uuid4().hex}"
|
||||
task = await tasks.submit(
|
||||
user_id=user_id,
|
||||
task_type=TaskType.PING,
|
||||
idempotency_key=idempotency_key,
|
||||
payload={"echo": payload.echo},
|
||||
eta_seconds=2,
|
||||
)
|
||||
if task.status == TaskStatus.PENDING.value:
|
||||
task = await tasks.transition(
|
||||
task_id=task.task_id, target=TaskStatus.QUEUED, stage_text="排队中"
|
||||
)
|
||||
ping_task.apply_async(args=[task.task_id], queue="cpu")
|
||||
return ok(_serialize(task))
|
||||
|
||||
|
||||
@router.get("/{task_id}", summary="Task status (ownership checked)")
|
||||
async def get_task(
|
||||
task_id: int,
|
||||
user_id: CurrentUserId,
|
||||
tasks: TaskServiceDep,
|
||||
) -> dict[str, object]:
|
||||
return ok(_serialize(await tasks.get(task_id=task_id, user_id=user_id)))
|
||||
|
||||
|
||||
@router.get("", summary="Task centre list")
|
||||
async def list_tasks(
|
||||
user_id: CurrentUserId,
|
||||
tasks: TaskServiceDep,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
) -> dict[str, object]:
|
||||
rows = await tasks.list_for_user(user_id=user_id, limit=limit)
|
||||
return ok([_serialize(row) for row in rows])
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
"""Request/response schemas.
|
||||
|
||||
NFR-MAINT-01 契约先行: schema 先定义,客户端类型由 OpenAPI 生成。
|
||||
Wire responses are wrapped by the unified envelope (架构文档 §9.2); these models
|
||||
describe the ``data`` payload only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DevTokenRequest(BaseModel):
|
||||
device_key: str = Field(
|
||||
default="local-dev",
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
description="任意稳定字符串;同一 device_key 复用同一测试用户",
|
||||
)
|
||||
|
||||
|
||||
class DevTokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "Bearer" # noqa: S105 — a scheme name, not a credential
|
||||
expires_in: int
|
||||
user_id: str = Field(description="雪花 ID 以字符串返回,避免 JS 精度丢失")
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str = Field(min_length=1, max_length=2000)
|
||||
request_key: str | None = Field(
|
||||
default=None,
|
||||
max_length=128,
|
||||
description="幂等键;相同键不会重复扣配额 (架构文档 §9.1)",
|
||||
)
|
||||
|
||||
|
||||
class ChatUsage(BaseModel):
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
tokens_cached: int
|
||||
latency_ms: int
|
||||
cost_cents: str
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
reply: str
|
||||
provider: str
|
||||
model: str
|
||||
prompt_id: str | None
|
||||
prompt_version: str | None
|
||||
call_id: str
|
||||
usage: ChatUsage
|
||||
quota_remaining: int
|
||||
|
||||
|
||||
class QuotaResponse(BaseModel):
|
||||
quota_key: str
|
||||
period_key: str
|
||||
used: int
|
||||
limit_value: int
|
||||
bonus_value: int
|
||||
remaining: int
|
||||
reset_at: datetime | None
|
||||
|
||||
|
||||
class LedgerEntry(BaseModel):
|
||||
tx_id: str
|
||||
delta: int
|
||||
tx_type: str
|
||||
status: str
|
||||
reason: str | None
|
||||
quota_key: str | None
|
||||
period_key: str | None
|
||||
balance_after: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TaskSubmitRequest(BaseModel):
|
||||
echo: str = Field(default="ping", max_length=200)
|
||||
idempotency_key: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
task_id: str
|
||||
task_type: str
|
||||
status: str
|
||||
progress: int
|
||||
stage_text: str | None
|
||||
result: dict[str, object] | None
|
||||
error_code: int | None
|
||||
error_message: str | None
|
||||
retry_count: int
|
||||
submitted_at: datetime
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
@@ -0,0 +1 @@
|
||||
"""Celery 任务定义与四队列配置(gpu/cpu/io/beat)。"""
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
"""Celery application with the four queues from TD-11 / 架构文档 §3.1.
|
||||
|
||||
gpu 换装、发型、抠图 —— 并发上限由 GPU 容量决定,队列即容量 (§7.3)
|
||||
cpu 报告视觉分析、渲染
|
||||
io 外部 API 调用、推送、上传
|
||||
beat 定时任务(单实例,防重)
|
||||
|
||||
Queue isolation is the point: 换装排队不能把报告生成拖慢。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from celery import Celery
|
||||
from kombu import Queue
|
||||
|
||||
from packages.shared.config import get_settings
|
||||
from packages.shared.logging import configure_logging
|
||||
|
||||
settings = get_settings()
|
||||
configure_logging(
|
||||
level=settings.log_level,
|
||||
json_output=settings.log_json,
|
||||
extra_redact_keys=settings.extra_redact_keys,
|
||||
)
|
||||
|
||||
celery_app = Celery(
|
||||
"meida",
|
||||
broker=settings.celery_broker_url,
|
||||
backend=settings.celery_result_backend,
|
||||
include=["apps.worker.tasks"],
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_default_queue="cpu",
|
||||
task_queues=(
|
||||
Queue("gpu"),
|
||||
Queue("cpu"),
|
||||
Queue("io"),
|
||||
Queue("beat"),
|
||||
),
|
||||
task_routes={
|
||||
"meida.tryon.*": {"queue": "gpu"},
|
||||
"meida.matting.*": {"queue": "gpu"},
|
||||
"meida.report.*": {"queue": "cpu"},
|
||||
"meida.notify.*": {"queue": "io"},
|
||||
},
|
||||
task_acks_late=True,
|
||||
task_reject_on_worker_lost=True,
|
||||
task_track_started=True,
|
||||
worker_prefetch_multiplier=1,
|
||||
broker_connection_retry_on_startup=True,
|
||||
timezone="Asia/Shanghai",
|
||||
result_expires=86400,
|
||||
)
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
"""Celery task definitions.
|
||||
|
||||
Only the scaffold's ``ping`` task lives here. It proves the whole async path:
|
||||
API creates ``tk_task`` → queue → worker transitions running → succeeded →
|
||||
task centre reflects it. Real long-running tasks (report / tryon / matting /
|
||||
render) follow exactly this shape and additionally call
|
||||
``billing.commit()``/``refund()`` and ``compliance.publish_asset()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from apps.worker.celery_app import celery_app
|
||||
from packages.domain.task.state import TaskStatus
|
||||
from packages.shared.context import bind_context
|
||||
from packages.shared.db import get_session_factory
|
||||
from packages.shared.logging import get_logger
|
||||
from packages.task.service import TaskService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def _run_ping(task_id: int, echo: str | None = None) -> dict[str, Any]:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
service = TaskService(session)
|
||||
task = await service.transition(
|
||||
task_id=task_id, target=TaskStatus.RUNNING, stage_text="执行中"
|
||||
)
|
||||
payload = task.payload or {}
|
||||
message = echo or str(payload.get("echo", "pong"))
|
||||
await session.commit()
|
||||
|
||||
finished = await service.transition(
|
||||
task_id=task_id,
|
||||
target=TaskStatus.SUCCEEDED,
|
||||
stage_text="已完成",
|
||||
result={"echo": message},
|
||||
)
|
||||
await session.commit()
|
||||
return {"task_id": str(finished.task_id), "echo": message}
|
||||
|
||||
|
||||
async def _fail_ping(task_id: int, error: str) -> None:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
service = TaskService(session)
|
||||
await service.transition(
|
||||
task_id=task_id,
|
||||
target=TaskStatus.FAILED,
|
||||
stage_text="执行失败",
|
||||
error_message=error,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@celery_app.task(name="meida.task.ping", bind=True, max_retries=2)
|
||||
def ping_task(self: Any, task_id: int, echo: str | None = None) -> dict[str, Any]:
|
||||
bind_context(task_id=task_id)
|
||||
try:
|
||||
return asyncio.run(_run_ping(task_id, echo))
|
||||
except Exception as exc:
|
||||
logger.exception("ping_task_failed", task_id=task_id)
|
||||
# Real tasks additionally refund the quota reservation here (FR-BILL-05).
|
||||
asyncio.run(_fail_ping(task_id, f"{type(exc).__name__}: {exc}"))
|
||||
raise
|
||||
Reference in New Issue
Block a user