d42965bba1
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m32s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m33s
CI/CD Pipeline / Unit Tests (push) Successful in 4m24s
CI/CD Pipeline / Integration Tests (push) Successful in 2m22s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 9m32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m17s
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
193 lines
6.6 KiB
Python
Executable File
193 lines
6.6 KiB
Python
Executable File
"""Feature Flag 内部管理接口。
|
||
|
||
通过内部 API Key 鉴权,支持查看和修改 Feature Flag 配置。
|
||
主要用于灰度发布期间的动态开关控制。
|
||
|
||
API:
|
||
GET /api/v1/internal/feature-flags - 列出所有 flag
|
||
GET /api/v1/internal/feature-flags/{name} - 查看单个 flag
|
||
PUT /api/v1/internal/feature-flags/{name} - 设置 flag 配置
|
||
DELETE /api/v1/internal/feature-flags/{name} - 删除 flag
|
||
|
||
鉴权:X-API-Key header,走内部 API Key 验证
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Optional
|
||
|
||
from app.api.routes.auth import _verify_internal_api_key
|
||
from app.config import settings
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||
from pydantic import BaseModel, Field
|
||
|
||
from packages.adapters.redis.feature_flag_store import (
|
||
FeatureFlagConfig,
|
||
RedisFeatureFlagStore,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
|
||
|
||
# 允许管理的 flag 白名单(防止误操作其他系统 flag)
|
||
ALLOWED_FLAGS: set[str] = set()
|
||
|
||
|
||
def _get_feature_flag_store() -> RedisFeatureFlagStore:
|
||
"""获取 Feature Flag 存储实例。"""
|
||
return RedisFeatureFlagStore(redis_url=settings.REDIS_URL)
|
||
|
||
|
||
class FeatureFlagUpdateRequest(BaseModel):
|
||
"""Feature Flag 更新请求体。"""
|
||
|
||
enabled: bool = Field(..., description="是否启用")
|
||
percentage: int = Field(0, ge=0, le=100, description="灰度百分比 (0-100)")
|
||
whitelist: list[str] = Field(default_factory=list, description="白名单列表(如 user_id)")
|
||
|
||
|
||
class FeatureFlagResponse(BaseModel):
|
||
"""Feature Flag 响应。"""
|
||
|
||
name: str
|
||
enabled: bool
|
||
percentage: int
|
||
whitelist: list[str]
|
||
|
||
@classmethod
|
||
def from_config(cls, config: FeatureFlagConfig) -> "FeatureFlagResponse":
|
||
return cls(
|
||
name=config.name,
|
||
enabled=config.enabled,
|
||
percentage=config.percentage,
|
||
whitelist=sorted(config.whitelist),
|
||
)
|
||
|
||
|
||
class FeatureFlagCheckResponse(BaseModel):
|
||
"""Flag 激活检查响应。"""
|
||
|
||
name: str
|
||
active: bool
|
||
identifier: Optional[str] = None
|
||
|
||
|
||
def _validate_flag_name(name: str) -> None:
|
||
"""校验 flag 名称是否在允许列表中。"""
|
||
if name not in ALLOWED_FLAGS:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"Unsupported flag: {name}. Allowed: {sorted(ALLOWED_FLAGS)}",
|
||
)
|
||
|
||
|
||
@router.get("", response_model=list[FeatureFlagResponse])
|
||
async def list_feature_flags(
|
||
_: bool = Depends(_verify_internal_api_key),
|
||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||
) -> list[FeatureFlagResponse]:
|
||
"""列出所有 Feature Flag。"""
|
||
try:
|
||
flags = store.list_all()
|
||
# 同时返回预定义的 flag(即使未设置也显示默认值)
|
||
result = []
|
||
for name in sorted(ALLOWED_FLAGS):
|
||
config = flags.get(name) or FeatureFlagConfig(name=name, enabled=False)
|
||
result.append(FeatureFlagResponse.from_config(config))
|
||
# 加上已存在但不在白名单中的 flag(只读展示)
|
||
for name, config in flags.items():
|
||
if name not in ALLOWED_FLAGS:
|
||
result.append(FeatureFlagResponse.from_config(config))
|
||
return sorted(result, key=lambda x: x.name)
|
||
except Exception as exc:
|
||
logger.error("Failed to list feature flags: %s", exc)
|
||
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}") from exc
|
||
|
||
|
||
@router.get("/{name}", response_model=FeatureFlagResponse)
|
||
async def get_feature_flag(
|
||
name: str,
|
||
_: bool = Depends(_verify_internal_api_key),
|
||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||
) -> FeatureFlagResponse:
|
||
"""获取单个 Feature Flag 配置。"""
|
||
try:
|
||
config = store.get(name)
|
||
return FeatureFlagResponse.from_config(config)
|
||
except Exception as exc:
|
||
logger.error("Failed to get feature flag %s: %s", name, exc)
|
||
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}") from exc
|
||
|
||
|
||
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
|
||
async def check_feature_flag(
|
||
name: str,
|
||
identifier: Optional[str] = Query(None, description="标识符,如 user_id"),
|
||
_: bool = Depends(_verify_internal_api_key),
|
||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||
) -> FeatureFlagCheckResponse:
|
||
"""检查某个标识符是否命中 Feature Flag。"""
|
||
try:
|
||
active = store.is_active(name, identifier=identifier)
|
||
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
|
||
except Exception as exc:
|
||
logger.error("Failed to check feature flag %s: %s", name, exc)
|
||
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}") from exc
|
||
|
||
|
||
@router.put("/{name}", response_model=FeatureFlagResponse)
|
||
async def update_feature_flag(
|
||
name: str,
|
||
request: FeatureFlagUpdateRequest,
|
||
_: bool = Depends(_verify_internal_api_key),
|
||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||
) -> FeatureFlagResponse:
|
||
"""更新 Feature Flag 配置。
|
||
|
||
只允许修改 ALLOWED_FLAGS 列表中的 flag。
|
||
"""
|
||
_validate_flag_name(name)
|
||
|
||
try:
|
||
config = FeatureFlagConfig(
|
||
name=name,
|
||
enabled=request.enabled,
|
||
percentage=request.percentage,
|
||
whitelist=set(request.whitelist),
|
||
)
|
||
store.set(config)
|
||
logger.info(
|
||
"Feature flag updated: name=%s enabled=%s percentage=%d whitelist=%d",
|
||
name,
|
||
config.enabled,
|
||
config.percentage,
|
||
len(config.whitelist),
|
||
)
|
||
return FeatureFlagResponse.from_config(config)
|
||
except Exception as exc:
|
||
logger.error("Failed to update feature flag %s: %s", name, exc)
|
||
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}") from exc
|
||
|
||
|
||
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||
async def delete_feature_flag(
|
||
name: str,
|
||
_: bool = Depends(_verify_internal_api_key),
|
||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||
):
|
||
"""删除 Feature Flag。
|
||
|
||
只允许删除 ALLOWED_FLAGS 列表中的 flag。
|
||
"""
|
||
_validate_flag_name(name)
|
||
|
||
try:
|
||
deleted = store.delete(name)
|
||
logger.info("Feature flag deleted: name=%s deleted=%s", name, deleted)
|
||
pass
|
||
except Exception as exc:
|
||
logger.error("Failed to delete feature flag %s: %s", name, exc)
|
||
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") from exc
|