feat/ internal eval平台现支持实机联调。 1. 显示当前本地数据库里登录用户的历史聊天,已生成的回忆录。支持在网页直接对话,不依赖手机app。

This commit is contained in:
Kevin
2026-04-20 11:58:32 +08:00
parent 1194e1ed71
commit e848f26354
18 changed files with 1339 additions and 12 deletions

View File

@@ -183,6 +183,8 @@ MEMORY_COMPACTION_ENABLED=true
SECRET_KEY=replace_with_a_strong_random_secret
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=120
# 内网评测:开启后可用 POST /api/auth/mock/sms-login跳过短信APP_ENV=production 时该路由仍返回 404
# MOCK_SMS_LOGIN_ENABLED=1
# =============================================================================
# Tencent Cloud — 短信

View File

@@ -50,6 +50,8 @@ class Settings(BaseSettings):
algorithm: str = "HS256"
access_token_expire_minutes: int = 120
refresh_token_expire_days: int = 30
# 本地/内网评测:允许 POST /api/auth/mock/sms-login 跳过短信须显式开启production 下路由仍拒绝)
mock_sms_login_enabled: bool = False
# ── LLM / DeepSeek ───────────────────────────────────────
deepseek_api_key: str = ""
@@ -209,6 +211,15 @@ class Settings(BaseSettings):
return False
return str(v).strip().lower() in ("1", "true", "yes", "on")
@field_validator("mock_sms_login_enabled", mode="before")
@classmethod
def _coerce_mock_sms_login_enabled(cls, v: object) -> bool:
if isinstance(v, bool):
return v
if v is None:
return False
return str(v).strip().lower() in ("1", "true", "yes", "on")
@field_validator("log_agent_verbose", mode="before")
@classmethod
def _coerce_log_agent_verbose(cls, v: object) -> bool:

View File

@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from PIL import Image
from app.core.config import settings
from app.core.dependencies import get_current_user
from app.core.logging import get_logger
from app.features.auth.deps import get_auth_service
@@ -12,6 +13,7 @@ from app.features.auth.schemas import (
ChangePasswordRequest,
ChangePhoneRequest,
LoginRequest,
MockSmsLoginRequest,
RefreshTokenRequest,
RegisterRequest,
ResetPasswordRequest,
@@ -73,6 +75,13 @@ def _check_terms(agreed: bool) -> None:
)
def _mock_sms_login_route_enabled() -> bool:
env = (settings.app_environment or "").lower().strip()
if env == "production":
return False
return bool(settings.mock_sms_login_enabled)
# ── registration & login ─────────────────────────────────────
@@ -410,6 +419,36 @@ async def login_with_sms(
)
@router.post(
"/mock/sms-login",
response_model=TokenResponse,
summary="[评测] Mock 短信登录(跳过验证码)",
description=(
"需 MOCK_SMS_LOGIN_ENABLED=1 且 APP_ENV 非 production。"
"供 Eval Web 等内网工具联调,勿在生产环境开启。"
),
responses={404: {"description": "未启用或生产环境已禁用"}},
)
async def mock_sms_login_route(
request: MockSmsLoginRequest,
service: AuthService = Depends(get_auth_service),
):
if not _mock_sms_login_route_enabled():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found")
_check_terms(request.agreed_to_terms)
try:
result = await service.mock_sms_login(
phone=request.phone,
nickname=request.nickname,
)
except AuthError as e:
raise _map_auth_error(e)
return TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
)
@router.post(
"/register/sms",
response_model=TokenResponse,

View File

@@ -59,6 +59,16 @@ class SmsLoginRequest(BaseModel):
)
class MockSmsLoginRequest(BaseModel):
"""开发/评测专用:与 MOCK_SMS_LOGIN_ENABLED 联用,跳过短信校验。"""
phone: str = Field(..., min_length=11, max_length=11, description="手机号11位")
agreed_to_terms: bool = Field(..., description="是否同意用户协议和隐私政策")
nickname: Optional[str] = Field(
None, max_length=50, description="新用户昵称(可选)"
)
class SmsRegisterRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=11, description="手机号11位")
code: str = Field(..., min_length=6, max_length=6, description="验证码6位")

View File

@@ -218,6 +218,18 @@ class AuthService:
if not success:
raise AuthError(message, "INVALID_SMS_CODE")
return await self._sms_login_after_code_verified(
phone, device_info=device_info, nickname=nickname
)
async def _sms_login_after_code_verified(
self,
phone: str,
*,
device_info: str = "",
nickname: str | None = None,
) -> dict:
"""SMS 已校验通过后:查找或创建用户并签发令牌。"""
user = await repo.get_user_by_phone(phone, self._db)
is_new_user = user is None
@@ -240,6 +252,17 @@ class AuthService:
return {"user": user, "is_new_user": is_new_user, **tokens}
async def mock_sms_login(
self,
phone: str,
device_info: str = "",
nickname: str | None = None,
) -> dict:
"""跳过短信校验的登录/自动注册(仅由 mock 路由在配置允许时调用)。"""
return await self._sms_login_after_code_verified(
phone, device_info=device_info, nickname=nickname
)
async def register_with_sms(
self,
phone: str,

View File

@@ -62,6 +62,8 @@ VITE_EVAL_API_BASE=http://127.0.0.1:8001 VITE_EVAL_API_KEY=与上同 npm run dev
或使用仓库根目录 `npm run eval-web`(需本地已 `npm install``app-eval-web`)。
**部署约定**`app-eval-web` **不提供**生产/预发 Docker 镜像,也 **不**纳入 Staging/Production 的 `api/docker-compose.yml` 部署。评测 UI 仅在开发阶段于本机通过 Vite `npm run dev`(或 `./internal-eval.sh` 拉起)使用;不要在预发/生产环境编译 `dist/` 或挂载独立容器对外提供服务。
## 流式评审
`POST /internal/api/evaluation/judge/conversation-stream` 使用 **fetch 读取 SSE**chunk请求头携带 `X-Internal-Eval-Key` 即可;不要求浏览器 `EventSource`。Body 可选 **`judge_provider`**`zhipu`(默认)| `deepseek`,以及 **`judge_model`**(空则用该供应商环境默认)。首轮 `meta` 事件会回显 `judge_provider` / `judge_model`
@@ -76,6 +78,11 @@ VITE_EVAL_API_BASE=http://127.0.0.1:8001 VITE_EVAL_API_KEY=与上同 npm run dev
- **Playground · 分步测评**:选用户导出 MD 为基线 → `eval-sandbox` + 逐轮 `replay/conversation`**`skip_memoir: true`** 时只做对话)→ **`memoir-submit`** 再可选轮询 **`memoir-phase1-ready`** → 跳转 **Memoir / Stories** 看成稿;支持 **智谱 / DeepSeek R1** 对话流式评分(工具栏「评审模型」)。
- **Memoir**:按 `user_id` 拉库中章节快照与基线对照评审。
- **Stories**:故事列表与评审。
- **实机联调**(侧栏「实机联调」,哈希路由 `#live`):用与 **消费者主站**相同的 REST / WebSocket 测核心聊天与回忆录。需主站 `main:app` 已启动(默认 **:8000**),并在主站环境启用 Mock 登录(见下)。开发时 `app-eval-web`**`/api`** 与 **`/ws`** 代理到主站(`VITE_MAIN_API_PROXY_TARGET`,默认 `http://127.0.0.1:8000`);也可设 `VITE_MAIN_API_BASE` 直连完整主站 URL。
### Mock 登录(仅非 production
在主站 `.env` / `.env.development` 中设置 **`MOCK_SMS_LOGIN_ENABLED=1`**(或 `true`)。`APP_ENV=production`**`POST /api/auth/mock/sms-login` 始终返回 404**。请求体:`phone`11 位)、`agreed_to_terms: true`,可选 `nickname`(新用户);响应与正式短信登录相同(`access_token` + `refresh_token`)。**切勿在生产环境开启。**
## 真实链路透传回放(与 App 一致)

View File

@@ -0,0 +1,101 @@
"""Mock 短信登录路由:配置门禁与返回的 JWT 可解析性。"""
from __future__ import annotations
import uuid
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.core.config import settings
from app.core.security import create_access_token, verify_token
from app.features.auth.deps import get_auth_service
from app.features.auth.router import router as auth_router
from app.features.auth.service import AuthService
@pytest.fixture
def mock_sms_app() -> FastAPI:
app = FastAPI()
app.include_router(auth_router)
mock_service = MagicMock(spec=AuthService)
uid = str(uuid.uuid4())
mock_service.mock_sms_login = AsyncMock(
return_value={
"access_token": create_access_token(data={"sub": uid}),
"refresh_token": "rt-mock",
}
)
app.dependency_overrides[get_auth_service] = lambda: mock_service
app.state._mock_service = mock_service
app.state._mock_uid = uid
return app
@pytest.mark.asyncio
async def test_mock_sms_login_disabled_returns_404(mock_sms_app, monkeypatch) -> None:
monkeypatch.setattr(settings, "mock_sms_login_enabled", False)
monkeypatch.setattr(settings, "app_environment", "development")
transport = ASGITransport(app=mock_sms_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.post(
"/api/auth/mock/sms-login",
json={"phone": "13800138000", "agreed_to_terms": True},
)
assert r.status_code == 404
@pytest.mark.asyncio
async def test_mock_sms_login_production_returns_404(mock_sms_app, monkeypatch) -> None:
monkeypatch.setattr(settings, "mock_sms_login_enabled", True)
monkeypatch.setattr(settings, "app_environment", "production")
transport = ASGITransport(app=mock_sms_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.post(
"/api/auth/mock/sms-login",
json={"phone": "13800138000", "agreed_to_terms": True},
)
assert r.status_code == 404
@pytest.mark.asyncio
async def test_mock_sms_login_enabled_returns_valid_access_jwt(
mock_sms_app, monkeypatch
) -> None:
monkeypatch.setattr(settings, "mock_sms_login_enabled", True)
monkeypatch.setattr(settings, "app_environment", "development")
transport = ASGITransport(app=mock_sms_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.post(
"/api/auth/mock/sms-login",
json={"phone": "13800138000", "agreed_to_terms": True},
)
assert r.status_code == 200
body = r.json()
assert body.get("token_type") == "bearer"
access = body["access_token"]
payload = verify_token(access)
assert payload is not None
assert payload.get("type") == "access"
assert payload.get("sub") == mock_sms_app.state._mock_uid
svc: MagicMock = mock_sms_app.state._mock_service
svc.mock_sms_login.assert_awaited_once_with(
phone="13800138000",
nickname=None,
)
@pytest.mark.asyncio
async def test_mock_sms_login_requires_terms(mock_sms_app, monkeypatch) -> None:
monkeypatch.setattr(settings, "mock_sms_login_enabled", True)
monkeypatch.setattr(settings, "app_environment", "development")
transport = ASGITransport(app=mock_sms_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.post(
"/api/auth/mock/sms-login",
json={"phone": "13800138000", "agreed_to_terms": False},
)
assert r.status_code == 400