feat: add advisor rollout gate and rollback playbook

This commit is contained in:
Windows
2026-09-11 20:28:21 +08:00
parent fbb1171a45
commit f5dd5b8ba2
11 changed files with 253 additions and 35 deletions
+2 -1
View File
@@ -6,11 +6,12 @@ from app.api.dependencies.auth import build_request_context
from app.api.dependencies.rate_limit import enforce_rate_limit
from app.api.schemas.asset_allocation import AssetAllocationQuery
from app.core.contracts import RequestContext
from app.service.advisor_rollout_service import enforce_advisor_rollout
from app.service.asset_allocation_service import AssetAllocationService
router = APIRouter(
prefix="/api/v1/advisor", tags=["advisor-asset-allocation"],
dependencies=[Depends(enforce_rate_limit)],
dependencies=[Depends(enforce_rate_limit), Depends(enforce_advisor_rollout)],
)
+2 -1
View File
@@ -13,11 +13,12 @@ from app.api.schemas.investment_goals import (
InvestmentGoalCreate,
)
from app.core.contracts import RequestContext
from app.service.advisor_rollout_service import enforce_advisor_rollout
from app.service.investment_goal_service import InvestmentGoalService
router = APIRouter(
prefix="/api/v1/advisor", tags=["advisor-investment-goals"],
dependencies=[Depends(enforce_rate_limit)],
dependencies=[Depends(enforce_rate_limit), Depends(enforce_advisor_rollout)],
)
+2 -1
View File
@@ -6,11 +6,12 @@ from app.api.dependencies.auth import build_request_context
from app.api.dependencies.rate_limit import enforce_rate_limit
from app.api.schemas.portfolio_analysis import PortfolioAnalysisQuery
from app.core.contracts import RequestContext
from app.service.advisor_rollout_service import enforce_advisor_rollout
from app.service.portfolio_analysis_service import PortfolioAnalysisService
router = APIRouter(
prefix="/api/v1/advisor", tags=["advisor-portfolio-analysis"],
dependencies=[Depends(enforce_rate_limit)],
dependencies=[Depends(enforce_rate_limit), Depends(enforce_advisor_rollout)],
)
+10 -3
View File
@@ -8,12 +8,13 @@ from app.api.dependencies.auth import build_request_context
from app.api.dependencies.rate_limit import enforce_rate_limit
from app.core.contracts import RequestContext
from app.core.product_recommendation_contracts import ProductRecommendationQuery
from app.service.advisor_rollout_service import enforce_advisor_rollout
from app.service.product_recommendation_service import ProductRecommendationService
advisor_router = APIRouter(
prefix="/api/v1/advisor",
tags=["advisor-recommendations"],
dependencies=[Depends(enforce_rate_limit)],
dependencies=[Depends(enforce_rate_limit), Depends(enforce_advisor_rollout)],
)
admin_router = APIRouter(
prefix="/api/v1/admin",
@@ -40,7 +41,10 @@ async def published_recommendations(
return await ProductRecommendationService().published(context)
@admin_router.post("/advisor/recommendations/{content_id}/reviews")
@admin_router.post(
"/advisor/recommendations/{content_id}/reviews",
dependencies=[Depends(enforce_advisor_rollout)],
)
async def review_recommendation(
payload: dict[str, Any],
content_id: int = Path(gt=0),
@@ -58,7 +62,10 @@ async def review_recommendation(
return await ProductRecommendationService().review(content_id, decision, comment, context, key)
@admin_router.post("/advisor/recommendations/{content_id}/publications")
@admin_router.post(
"/advisor/recommendations/{content_id}/publications",
dependencies=[Depends(enforce_advisor_rollout)],
)
async def publish_recommendation(
content_id: int = Path(gt=0),
context: RequestContext = Depends(build_request_context), # noqa: B008
+4
View File
@@ -58,6 +58,10 @@ class Settings(BaseSettings):
worker_lease_seconds: int = Field(default=60, gt=0)
worker_retry_limit: int = Field(default=3, ge=0)
worker_poll_seconds: float = Field(default=1, gt=0)
# 投顾灰度默认关闭,只有显式开启并配置客户白名单后才限制客户流量。
# 管理员角色始终可进入,便于审核、发布和故障处置。
advisor_rollout_enabled: bool = False
advisor_rollout_customer_ids: str = ""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
+63
View File
@@ -0,0 +1,63 @@
"""投顾灰度发布闸门。
灰度配置使用环境变量,避免为发布控制改动冻结的数据库基线。白名单只允许客户
身份进入投顾业务;管理员保留审核、发布和故障处置权限。未命中时统一复用权限拒绝
错误,不向客户暴露灰度配置细节。
"""
from datetime import UTC, datetime
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.errors import ForbiddenAgentError
from app.model.audit import InteractionAudit
class AdvisorRolloutService:
ADMIN_ROLES = frozenset({"admin", "super_admin"})
def __init__(self, session: AsyncSession | None = None) -> None:
self.session = session
@staticmethod
def customer_ids(raw: str) -> frozenset[str]:
return frozenset(item.strip() for item in raw.split(",") if item.strip())
@classmethod
def is_allowed(cls, context: RequestContext) -> bool:
settings = get_settings()
if not settings.advisor_rollout_enabled:
return True
if cls.ADMIN_ROLES.intersection(context.roles):
return True
allowed = cls.customer_ids(settings.advisor_rollout_customer_ids)
identities = {context.user_id, *context.customer_ids}
return bool(allowed.intersection(identities))
async def ensure_allowed(self, context: RequestContext) -> None:
if self.is_allowed(context):
return
if self.session is not None:
self.session.add(InteractionAudit(
actor_type="user",
actor_id=int(context.user_id),
portal=context.portal,
action_type="advisor.rollout_denied",
detail={"trace_id": context.trace_id, "reason": "not_in_rollout"},
created_at=datetime.now(UTC).replace(tzinfo=None),
))
await self.session.commit()
raise ForbiddenAgentError("当前投顾功能尚未对该账号开放")
async def enforce_advisor_rollout(
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
) -> None:
"""FastAPI 依赖:保护投顾业务路由,认证依赖先完成身份解析。"""
await AdvisorRolloutService(session).ensure_allowed(context)
@@ -19,6 +19,7 @@ from app.model.conversation import ConversationMessage
from app.model.platform import AgentRun, RequestIdempotency
from app.model.session import ConversationSession
from app.repository.outbox_repository import OutboxRepository
from app.service.advisor_rollout_service import AdvisorRolloutService
from app.service.agent.bootstrap import get_agent_factory
from app.service.agent.factory import AgentFactory
@@ -36,6 +37,8 @@ class AgentRunApplicationService:
self.factory = factory if factory is not None else get_agent_factory()
async def accept(self, request: AgentRequest, context: RequestContext) -> RunAccepted:
if request.agent_type == "advisor":
await AdvisorRolloutService(self.session).ensure_allowed(context)
try:
self.factory.authorize(request.agent_type, context)
except ForbiddenAgentError: