feat: add advisor rollout gate and rollback playbook
This commit is contained in:
@@ -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)],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user