merge dark mode and google OAuth (#35)

* feat(api): implement Google OAuth login and user management

- Added Google OpenID Connect login functionality, allowing users to authenticate using their Google accounts.
- Created new endpoints for Google login, including user registration and linking existing accounts.
- Introduced Google token verification logic and error handling for authentication failures.
- Updated environment configuration to include Google OAuth client IDs and verification settings.
- Enhanced user model to support OpenID and linked Google accounts.

This feature improves user experience by enabling seamless sign-in with Google, while maintaining security and integrity of user data.

* fix(auth): wire staging Google token verifier

* chore(deps): update expo to version 55.0.6 and adjust @expo/env dependency in pnpm-lock.yaml

* chore(deps): update Babel dependencies to version 7.29.7 in package-lock.json

* feat(auth): enhance phone login for China users

- Updated phone login functionality to support only mainland China (+86) mobile numbers.
- Added user prompts and descriptions for phone login, including confirmation and cancellation options.
- Adjusted translations for both English and Chinese to reflect the new phone login requirements.
- Updated Google OAuth client IDs in configuration files for production and staging environments.

* chore(deps): add peer flag to use-sync-external-store in package-lock.json

* chore(deps): add @emnapi/core and @emnapi/runtime to package-lock.json

* fix(app-expo): align Android native dependencies

* fix(app-expo): normalize lockfile for npm 10

* fix(config): update environment variable handling to use static access

- Introduced a static mapping for public environment variables to ensure proper access during the release bundle.
- Updated the `requirePublicEnv` and `optionalPublicEnv` functions to reference the new `PUBLIC_ENV` object instead of directly accessing `process.env`.
- Added comments to clarify the necessity of static access for certain environment variables.

* feat(app-expo): dark mode, FAQ i18n, eval ASR, and theme cleanup (#34)

* feat(app-expo): dark mode, FAQ i18n, version CI, and theme cleanup

Implement light/dark scene colors across chat, reading, and headers; remove
default/brand theme picker and ThemeVariablesProvider. Localize FAQ in-app,
fix dark-mode text visibility, and remove the unused /api/faqs endpoint.
Align About/version with Expo config and inject APP_VERSION in CI builds.

Also includes phone E164 auth/SMS updates, eval ASR page, and related API work.

* revert: remove phone E.164 changes from dark-mode branch

These auth/SMS internationalization updates were accidentally bundled into
the dark-mode commit; restore 11-digit CN phone flow and drop related API,
migration, and Expo UI work from this branch.

* fix: address PR review issues for dark mode and eval ASR

Use light foreground colors for sepia reading in dark mode, fix chat send
button contrast, stream-limit eval ASR uploads, restore LiveTester phone
validation, and remove unused AudioSegmenter code.

* fix(app-expo): improve chat send button contrast in light and dark mode

Add dedicated send button colors (accent fill in dark, primary fill in
light), use RNText to avoid NativeWind overrides, and restore dark labels
in light mode for readable composer actions.

---------

Co-authored-by: Kevin <kevin@brighteng.org>

---------

Co-authored-by: penghanyuan <penghanyuan@gmail.com>
Co-authored-by: Kevin <kevin@brighteng.org>
This commit is contained in:
Sully
2026-06-09 11:14:36 +08:00
committed by GitHub
parent 10d9e13f14
commit 105b50a277
105 changed files with 22363 additions and 6105 deletions

View File

@@ -16,6 +16,9 @@ class DeployConfig(BaseModel):
tencent_sms_sdk_app_id: str = ""
tencent_sms_sign_name: str = ""
tencent_sms_template_id: str = ""
google_oauth_client_ids: str = ""
google_token_verifier_url: str = ""
google_token_verifier_timeout_seconds: float = Field(default=5.0, gt=0, le=30)
tencent_cos_bucket: str = ""
tencent_cos_base_url: str = ""
enable_tts: bool = True

View File

@@ -56,6 +56,8 @@ class Settings(BaseSettings):
internal_eval_api_key: str = ""
google_token_verifier_secret: str = ""
@model_validator(mode="after")
def _validate_secret_key(self) -> "Settings":
env = (self.app_environment or "").strip().lower()

View File

@@ -0,0 +1,191 @@
"""Google OpenID Connect token verification."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
import httpx
import jwt
from jwt import PyJWKClient
from app.core.config import settings
from app.core.errors import ProviderError, ServiceUnavailableError
from app.core.logging import get_logger
from app.features.auth.service_errors import AuthError
logger = get_logger(__name__)
GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
GOOGLE_ISSUERS = {"accounts.google.com", "https://accounts.google.com"}
_jwks_client = PyJWKClient(GOOGLE_JWKS_URL)
@dataclass(frozen=True)
class GoogleIdentity:
subject: str
email: str
email_verified: bool
name: str
picture: str | None
audience: str = ""
def _configured_client_ids() -> list[str]:
raw = (settings.google_oauth_client_ids or "").strip()
return [part.strip() for part in raw.split(",") if part.strip()]
def verify_google_id_token(id_token: str) -> GoogleIdentity:
"""Verify a Google ID token and return the minimal identity claims."""
client_ids = _configured_client_ids()
if not client_ids:
raise ServiceUnavailableError("Google 登录尚未配置")
verifier_url = (settings.google_token_verifier_url or "").strip()
if verifier_url:
return _verify_google_id_token_remotely(id_token, client_ids, verifier_url)
return _verify_google_id_token_locally(id_token, client_ids)
def _verify_google_id_token_locally(
id_token: str,
client_ids: list[str],
) -> GoogleIdentity:
try:
signing_key = _jwks_client.get_signing_key_from_jwt(id_token)
payload = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256"],
audience=client_ids,
options={"verify_iss": False},
)
except jwt.InvalidAudienceError as exc:
raise AuthError("Google 登录客户端不匹配", "INVALID_TOKEN") from exc
except jwt.ExpiredSignatureError as exc:
raise AuthError("Google 登录凭证已过期,请重试", "INVALID_TOKEN") from exc
except jwt.InvalidTokenError as exc:
raise AuthError("Google 登录凭证无效", "INVALID_TOKEN") from exc
except Exception as exc:
logger.exception("Google JWKS/token verification failed: {}", exc)
raise ProviderError("Google 登录校验暂时不可用,请稍后重试") from exc
issuer = str(payload.get("iss") or "")
if issuer not in GOOGLE_ISSUERS:
raise AuthError("Google 登录凭证来源无效", "INVALID_TOKEN")
audience = _matched_audience(payload, client_ids)
return _identity_from_claims(payload, audience=audience)
def _verify_google_id_token_remotely(
id_token: str,
client_ids: list[str],
verifier_url: str,
) -> GoogleIdentity:
secret = (settings.google_token_verifier_secret or "").strip()
if not secret:
raise ServiceUnavailableError("Google 登录校验代理未配置")
try:
response = httpx.post(
verifier_url,
json={"id_token": id_token},
headers={
"Accept": "application/json",
"Authorization": f"Bearer {secret}",
},
timeout=settings.google_token_verifier_timeout_seconds or 5.0,
)
except (httpx.TimeoutException, httpx.RequestError) as exc:
logger.warning("Google token verifier request failed: {}", exc)
raise ProviderError("Google 登录校验暂时不可用,请稍后重试") from exc
try:
data = response.json()
except ValueError as exc:
logger.warning(
"Google token verifier returned non-JSON response: status={}",
response.status_code,
)
raise ProviderError("Google 登录校验暂时不可用,请稍后重试") from exc
if not isinstance(data, dict):
logger.warning(
"Google token verifier returned invalid JSON shape: status={}",
response.status_code,
)
raise ProviderError("Google 登录校验暂时不可用,请稍后重试")
if response.status_code != 200:
_raise_remote_verifier_error(response.status_code, data)
audience = str(data.get("audience") or "").strip()
if audience not in client_ids:
raise AuthError("Google 登录客户端不匹配", "INVALID_TOKEN")
return _identity_from_claims(data, audience=audience)
def _raise_remote_verifier_error(
status_code: int,
data: Mapping[str, Any],
) -> None:
code = str(data.get("error") or "")
if code == "INVALID_TOKEN" or (status_code in (400, 401) and not code):
raise AuthError("Google 登录凭证无效", "INVALID_TOKEN")
if code == "INVALID_REQUEST":
raise AuthError("Google 登录凭证无效", "INVALID_TOKEN")
if code in {"CONFIGURATION_ERROR", "UNAUTHORIZED"}:
raise ServiceUnavailableError("Google 登录校验代理配置错误")
logger.warning(
"Google token verifier returned error: status={} code={}",
status_code,
code or "<empty>",
)
raise ProviderError("Google 登录校验暂时不可用,请稍后重试")
def _matched_audience(payload: Mapping[str, Any], client_ids: list[str]) -> str:
raw_audience = payload.get("aud")
audiences = (
raw_audience
if isinstance(raw_audience, list)
else [raw_audience]
if raw_audience is not None
else []
)
for audience in audiences:
audience_str = str(audience or "").strip()
if audience_str in client_ids:
return audience_str
raise AuthError("Google 登录客户端不匹配", "INVALID_TOKEN")
def _identity_from_claims(
payload: Mapping[str, Any],
*,
audience: str,
) -> GoogleIdentity:
subject = str(payload.get("sub") or payload.get("subject") or "").strip()
email = str(payload.get("email") or "").strip().lower()
email_verified = (
payload.get("email_verified") is True
or str(payload.get("email_verified")).lower() == "true"
)
if not subject or not email or not email_verified:
raise AuthError("Google 账号邮箱未验证,无法登录", "INVALID_TOKEN")
return GoogleIdentity(
subject=subject,
email=email,
email_verified=email_verified,
name=str(payload.get("name") or "").strip(),
picture=str(payload.get("picture") or "").strip() or None,
audience=audience,
)

View File

@@ -24,6 +24,12 @@ async def get_user_by_email(email: str, db: AsyncSession) -> User | None:
return result.scalar_one_or_none()
async def get_user_by_openid(openid: str, db: AsyncSession) -> User | None:
stmt = select(User).where(User.openid == openid)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def get_refresh_token_by_token(
token_str: str, db: AsyncSession
) -> RefreshToken | None:

View File

@@ -25,6 +25,7 @@ from app.features.auth.schemas import (
AvatarPresetItem,
ChangePasswordRequest,
ChangePhoneRequest,
GoogleLoginRequest,
LoginRequest,
MockSmsLoginRequest,
RefreshTokenRequest,
@@ -53,6 +54,7 @@ AVATAR_DIR = Path("uploads/avatars")
# ── helpers ──────────────────────────────────────────────────
def _user_response(user: User) -> UserResponse:
raw_lang = getattr(user, "language_preference", "zh")
lang = str(raw_lang).strip().lower() if isinstance(raw_lang, str) else "zh"
@@ -366,6 +368,36 @@ async def login_with_sms(
)
@router.post(
"/login/google",
response_model=TokenResponse,
summary="Google 账号登录(新用户自动注册)",
responses=error_responses(
400,
401,
503,
descriptions={
400: "未同意协议或参数错误",
401: "Google 登录凭证无效",
503: "Google 登录尚未配置",
},
),
)
async def login_with_google(
request: GoogleLoginRequest,
service: AuthService = Depends(get_auth_service),
):
_check_terms(request.agreed_to_terms)
result = await service.login_with_google(
id_token=request.id_token,
language=request.language,
)
return TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
)
@router.post(
"/mock/sms-login",
response_model=TokenResponse,

View File

@@ -70,6 +70,15 @@ class SmsLoginRequest(BaseModel):
)
class GoogleLoginRequest(BaseModel):
id_token: str = Field(min_length=20, description="Google OpenID Connect ID token")
agreed_to_terms: bool = Field(description="是否同意用户协议和隐私政策")
language: Optional[LanguagePreference] = Field(
None,
description="device language at signup; only used when creating a new user",
)
class MockSmsLoginRequest(BaseModel):
"""开发/评测专用:与 MOCK_SMS_LOGIN_ENABLED 联用,跳过短信校验。"""

View File

@@ -16,7 +16,6 @@ from app.core.cos_url_keys import (
)
from app.core.db import transactional, transactional_nested, utc_now
from app.core.errors import (
AppError,
BadRequestError,
ProviderError,
RateLimitedError,
@@ -33,8 +32,13 @@ from app.core.security import (
create_refresh_token as generate_refresh_token_str,
)
from app.features.auth import repo
from app.features.auth.integrity import is_user_phone_unique_violation, user_integrity_auth_code
from app.features.auth.google import GoogleIdentity, verify_google_id_token
from app.features.auth.integrity import (
is_user_phone_unique_violation,
user_integrity_auth_code,
)
from app.features.auth.models import RefreshToken, SmsVerificationCode
from app.features.auth.service_errors import AuthError
from app.features.user.models import User
from app.ports.sms import SmsSender
from app.ports.storage import ObjectStorage
@@ -75,29 +79,6 @@ def _as_utc(dt: datetime) -> datetime:
return dt.astimezone(timezone.utc)
_AUTH_CODE_MAP: dict[str, tuple[int, str]] = {
"INVALID_CREDENTIALS": (401, "AUTHENTICATION_FAILED"),
"INVALID_TOKEN": (401, "AUTHENTICATION_FAILED"),
"TOKEN_REVOKED": (401, "AUTHENTICATION_FAILED"),
"TOKEN_EXPIRED": (401, "AUTHENTICATION_FAILED"),
"REFRESH_TOKEN_REUSE": (401, "REFRESH_TOKEN_REUSE"),
"USER_NOT_FOUND": (404, "NOT_FOUND"),
"PHONE_EXISTS": (400, "PHONE_EXISTS"),
"EMAIL_EXISTS": (400, "EMAIL_EXISTS"),
"PHONE_TAKEN": (409, "PHONE_TAKEN"),
"INVALID_SMS_CODE": (400, "INVALID_SMS_CODE"),
"WRONG_PASSWORD": (400, "WRONG_PASSWORD"),
"AUTH_ERROR": (400, "BAD_REQUEST"),
}
class AuthError(AppError):
def __init__(self, message: str, code: str = "AUTH_ERROR"):
status_code, error_code = _AUTH_CODE_MAP.get(code, (400, code))
super().__init__(message, status_code=status_code, error_code=error_code)
self.code = code
def _raise_auth_error_from_user_integrity(
exc: IntegrityError,
*,
@@ -134,6 +115,22 @@ def _public_tokens(issued: dict) -> dict:
}
def _google_openid(subject: str) -> str:
return f"google:{subject}"
def _google_internal_phone(subject: str) -> str:
return f"google:{subject}"
def _nickname_from_google_identity(identity: GoogleIdentity) -> str:
name = identity.name.strip()
if name:
return name[:50]
local_part = identity.email.split("@", 1)[0].strip()
return (local_part or "Google User")[:50]
class AuthService:
def __init__(
self,
@@ -369,6 +366,54 @@ class AuthService:
tokens = await self._issue_tokens(user.id, device_info)
return {"user": user, **_public_tokens(tokens)}
async def login_with_google(
self,
id_token: str,
device_info: str = "",
language: str | None = None,
) -> dict:
"""Google OpenID Connect login. Auto-registers a user on first sign-in."""
identity = await asyncio.to_thread(verify_google_id_token, id_token)
openid = _google_openid(identity.subject)
user = await repo.get_user_by_openid(openid, self._db)
is_new_user = False
async with transactional(self._db):
if user is None:
user = await repo.get_user_by_email(identity.email, self._db)
if user is not None:
if user.openid and user.openid != openid:
raise AuthError("该邮箱已绑定其他第三方账号", "EMAIL_EXISTS")
user.openid = openid
if not user.email:
user.email = identity.email
if identity.picture and not user.avatar_url:
user.avatar_url = identity.picture
else:
is_new_user = True
user = User(
id=str(uuid.uuid4()),
phone=_google_internal_phone(identity.subject),
password_hash=hash_password(secrets.token_urlsafe(32)),
email=identity.email,
openid=openid,
nickname=_nickname_from_google_identity(identity),
avatar_url=identity.picture,
subscription_type="free",
created_at=datetime.now(timezone.utc),
language_preference=_normalize_language(language),
)
await _create_user_with_integrity_check(
self._db, user, phone_conflict="PHONE_EXISTS"
)
tokens = await self._issue_tokens(user.id, device_info)
if is_new_user:
await self._db.refresh(user)
return {"user": user, "is_new_user": is_new_user, **_public_tokens(tokens)}
async def _revoke_all_active_tokens_in_session(self, user_id: str) -> int:
"""Revoke all active refresh tokens on the current session (no commit)."""
tokens = await repo.get_active_tokens_for_user(user_id, self._db)
@@ -413,9 +458,7 @@ class AuthService:
if idempotent is None and not token_record.replaced_by_token_id:
# Concurrent refresh may observe revoke before lineage commits.
token_record = (
await repo.get_refresh_token_by_token(
refresh_token, self._db
)
await repo.get_refresh_token_by_token(refresh_token, self._db)
or token_record
)
idempotent = await self._try_idempotent_refresh_within_grace(
@@ -733,7 +776,9 @@ class AuthService:
jpeg_bytes = await _process_avatar_jpeg_async(file_content)
cos_key = f"avatars/{user_id}.jpg"
old_key = extract_cos_object_key_if_owned(old_avatar_url) if old_avatar_url else None
old_key = (
extract_cos_object_key_if_owned(old_avatar_url) if old_avatar_url else None
)
if not self._object_storage:
raise ServiceUnavailableError("头像存储服务未配置,请稍后再试")

View File

@@ -0,0 +1,25 @@
"""Shared auth-specific exceptions."""
from app.core.errors import AppError
_AUTH_CODE_MAP: dict[str, tuple[int, str]] = {
"INVALID_CREDENTIALS": (401, "AUTHENTICATION_FAILED"),
"INVALID_TOKEN": (401, "AUTHENTICATION_FAILED"),
"TOKEN_REVOKED": (401, "AUTHENTICATION_FAILED"),
"TOKEN_EXPIRED": (401, "AUTHENTICATION_FAILED"),
"REFRESH_TOKEN_REUSE": (401, "REFRESH_TOKEN_REUSE"),
"USER_NOT_FOUND": (404, "NOT_FOUND"),
"PHONE_EXISTS": (400, "PHONE_EXISTS"),
"EMAIL_EXISTS": (400, "EMAIL_EXISTS"),
"PHONE_TAKEN": (409, "PHONE_TAKEN"),
"INVALID_SMS_CODE": (400, "INVALID_SMS_CODE"),
"WRONG_PASSWORD": (400, "WRONG_PASSWORD"),
"AUTH_ERROR": (400, "BAD_REQUEST"),
}
class AuthError(AppError):
def __init__(self, message: str, code: str = "AUTH_ERROR"):
status_code, error_code = _AUTH_CODE_MAP.get(code, (400, code))
super().__init__(message, status_code=status_code, error_code=error_code)
self.code = code

View File

@@ -1,141 +1,19 @@
"""
静态内容路由:FAQ、法律文档、官网主页。
静态内容路由:法律文档、官网主页。
"""
from pathlib import Path
from typing import List
from fastapi import APIRouter
from fastapi.responses import FileResponse
from app.core.openapi import error_responses
from app.features.content.schemas import FAQResponse
router = APIRouter(tags=["content"], responses=error_responses(404))
_STATIC_DIR = Path(__file__).resolve().parent.parent.parent.parent / "static"
# ── FAQ data ─────────────────────────────────────────────────
FAQS = [
FAQResponse(
id="1",
question="如何使用回忆录功能?",
answer="创建对话后与AI助手交流分享您的人生故事。AI会自动整理并生成回忆录章节。您可以在'我的回忆录'页面查看所有章节,并选择对话进行整理。",
category="使用指南",
order=1,
),
FAQResponse(
id="2",
question="免费版和高级版有什么区别?",
answer="免费版限制3次对话和10个章节高级版提供无限对话和章节以及优先处理服务。高级版用户还可以享受更快的处理速度和专属客服支持。",
category="订阅计划",
order=2,
),
FAQResponse(
id="3",
question="如何导出回忆录?",
answer="'我的回忆录'页面,您可以查看所有章节。导出功能正在开发中,敬请期待!",
category="使用指南",
order=3,
),
FAQResponse(
id="4",
question="数据安全吗?",
answer="我们采用加密存储,严格保护用户隐私,您的数据仅用于生成回忆录,不会用于其他用途。所有数据都经过加密处理,确保您的隐私安全。",
category="隐私安全",
order=4,
),
FAQResponse(
id="5",
question="如何升级到高级版?",
answer="'我的'页面点击'订阅计划',选择高级版并完成支付即可升级。升级后立即生效,享受所有高级功能。",
category="订阅计划",
order=5,
),
FAQResponse(
id="6",
question="可以修改已生成的章节吗?",
answer="可以,在章节详情页面可以编辑内容,修改后会自动保存。您也可以重新整理对话来更新章节内容。",
category="使用指南",
order=6,
),
FAQResponse(
id="7",
question="如何整理对话内容成章节?",
answer="'我的回忆录'页面,点击'整理对话'按钮选择要整理的对话AI会自动将对话内容整理成一个个小章节。每个章节展开后可以看到详细内容。",
category="使用指南",
order=7,
),
FAQResponse(
id="8",
question="章节是如何生成的?",
answer="AI会根据对话内容自动识别主题将相关内容整理成章节。每个章节都有标题和详细内容您可以随时查看和编辑。",
category="使用指南",
order=8,
),
FAQResponse(
id="9",
question="可以删除对话或章节吗?",
answer="可以,在对话列表或章节列表中,您可以长按或点击删除按钮来删除不需要的内容。删除后无法恢复,请谨慎操作。",
category="使用指南",
order=9,
),
FAQResponse(
id="10",
question="如何联系客服?",
answer="您可以在'我的'页面点击'反馈与客服',填写反馈表单或联系客服。我们会尽快回复您的问题。",
category="帮助支持",
order=10,
),
FAQResponse(
id="11",
question="回忆录支持哪些格式?",
answer="目前支持文本格式的回忆录。PDF导出功能正在开发中敬请期待",
category="使用指南",
order=11,
),
FAQResponse(
id="12",
question="如何备份我的数据?",
answer="您的数据会自动保存在云端,无需手动备份。导出功能正在开发中,完成后您可以导出数据到本地。",
category="数据管理",
order=12,
),
FAQResponse(
id="13",
question="语音功能什么时候上线?",
answer="语音模块正在开发中,包括语音输入和语音播放功能。敬请期待!",
category="功能预告",
order=13,
),
FAQResponse(
id="14",
question="可以多人协作编辑回忆录吗?",
answer="目前不支持多人协作,每个账号只能编辑自己的回忆录。多人协作功能正在规划中。",
category="功能预告",
order=14,
),
FAQResponse(
id="15",
question="如何提高回忆录的质量?",
answer="建议您详细描述每个话题提供更多细节和感受。AI会根据您提供的信息生成更丰富、更生动的回忆录内容。",
category="使用技巧",
order=15,
),
]
# ── Routes ───────────────────────────────────────────────────
@router.get("/api/faqs", response_model=List[FAQResponse])
async def get_faqs():
"""获取常见问题列表"""
return FAQS
@router.get("/api/legal/terms")
async def get_terms():
"""用户协议页面"""

View File

@@ -1,11 +0,0 @@
"""Content feature schemas."""
from pydantic import BaseModel
class FAQResponse(BaseModel):
id: str
question: str
answer: str
category: str
order: int

View File

@@ -0,0 +1,124 @@
"""内部评测 ASR 转写服务。"""
from __future__ import annotations
from fastapi import UploadFile
from app.core.agent_logging import log_asr_transcript_result
from app.core.errors import ProviderError
from app.core.logging import get_logger
from app.features.evaluation.errors import EvaluationBadRequestError
from app.ports.asr import ASRProvider, ASRTranscriptionError
logger = get_logger(__name__)
MAX_AUDIO_BYTES = 100 * 1024 * 1024
_READ_CHUNK_BYTES = 1024 * 1024
_ALLOWED_FORMATS = frozenset(
{"m4a", "mp3", "wav", "aac", "amr", "ogg-opus", "speex", "silk", "pcm"}
)
_EXT_TO_FORMAT: dict[str, str] = {
"m4a": "m4a",
"mp4": "m4a",
"mp3": "mp3",
"wav": "wav",
"aac": "aac",
"amr": "amr",
"ogg": "ogg-opus",
"opus": "ogg-opus",
"webm": "ogg-opus",
"pcm": "pcm",
"speex": "speex",
"silk": "silk",
}
_CONTENT_TYPE_TO_FORMAT: dict[str, str] = {
"audio/mp4": "m4a",
"audio/x-m4a": "m4a",
"audio/m4a": "m4a",
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/wave": "wav",
"audio/aac": "aac",
"audio/amr": "amr",
"audio/ogg": "ogg-opus",
"audio/webm": "ogg-opus",
}
def resolve_voice_format(
*,
explicit: str | None,
filename: str | None,
content_type: str | None,
) -> str:
if explicit:
fmt = explicit.strip().lower()
if fmt in _ALLOWED_FORMATS:
return fmt
raise EvaluationBadRequestError(f"不支持的音频格式:{fmt}")
if filename:
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext in _EXT_TO_FORMAT:
return _EXT_TO_FORMAT[ext]
if content_type:
base = content_type.split(";", 1)[0].strip().lower()
if base in _CONTENT_TYPE_TO_FORMAT:
return _CONTENT_TYPE_TO_FORMAT[base]
raise EvaluationBadRequestError(
"无法识别音频格式,请上传 m4a/mp3/wav 或在请求中指定 format 参数"
)
async def read_limited_upload(
file: UploadFile,
*,
max_bytes: int = MAX_AUDIO_BYTES,
) -> bytes:
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(_READ_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise EvaluationBadRequestError("音频过大(上限 100MB")
chunks.append(chunk)
return b"".join(chunks)
class EvalAsrService:
def __init__(self, asr: ASRProvider) -> None:
self._asr = asr
async def transcribe(
self,
audio: bytes,
*,
voice_format: str,
source_label: str = "eval_asr",
) -> str:
if not audio:
raise EvaluationBadRequestError("音频为空")
if len(audio) > MAX_AUDIO_BYTES:
raise EvaluationBadRequestError("音频过大(上限 100MB")
try:
text = await self._asr.transcribe(audio, voice_format)
except ASRTranscriptionError as e:
raise ProviderError(str(e), provider="asr") from e
log_asr_transcript_result(
logger,
text=text or "",
conversation_id=None,
source=source_label,
)
return text or ""

View File

@@ -36,3 +36,10 @@ def get_eval_judge_manual_service(db: DbDep) -> EvalJudgeManualService:
def get_memoir_readiness_service(db: DbDep) -> MemoirReadinessService:
return MemoirReadinessService(db)
def get_eval_asr_service() -> "EvalAsrService":
from app.core.dependencies import get_asr_provider
from app.features.evaluation.asr_service import EvalAsrService
return EvalAsrService(get_asr_provider())

View File

@@ -5,14 +5,21 @@ from __future__ import annotations
import json
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from fastapi.responses import StreamingResponse
from app.core.deps_types import DbDep
from app.core.errors import BadRequestError, NotFoundError
from app.core.memoir_pipeline_progress import get_pipeline_run_for_eval
from app.features.evaluation.admin_service import EvaluationAdminService
from app.features.evaluation.asr_service import (
EvalAsrService,
MAX_AUDIO_BYTES,
read_limited_upload,
resolve_voice_format,
)
from app.features.evaluation.deps import (
get_eval_asr_service,
get_eval_judge_manual_service,
get_evaluation_admin_service,
get_memoir_readiness_service,
@@ -29,6 +36,7 @@ from app.features.evaluation.judge_manual_service import EvalJudgeManualService
from app.features.evaluation.memoir_readiness_service import MemoirReadinessService
from app.features.evaluation.replay_service import ReplayConversationService
from app.features.evaluation.schemas import (
AsrTranscribeOut,
EvalSandboxOut,
ManualJudgeConversationBody,
ManualJudgeConversationOut,
@@ -69,6 +77,38 @@ async def eval_api_ping() -> dict[str, str | bool]:
return {"ok": True, "service": "life-echo-internal-eval"}
@router.post("/asr/transcribe", response_model=AsrTranscribeOut)
async def transcribe_audio_for_eval(
request: Request,
_auth: InternalEvalAuth,
svc: Annotated[EvalAsrService, Depends(get_eval_asr_service)],
file: UploadFile = File(...),
format: str | None = Query(
None,
description="可选 voice_formatm4a/mp3/wav 等);缺省时从文件名或 Content-Type 推断",
),
):
"""上传音频并调用与主站相同的腾讯云 ASR 极速版转写16k_zh_large"""
content_length = request.headers.get("content-length")
if content_length is not None:
try:
if int(content_length) > MAX_AUDIO_BYTES:
raise EvaluationBadRequestError("音频过大(上限 100MB")
except ValueError:
pass
audio_bytes = await read_limited_upload(file)
voice_format = resolve_voice_format(
explicit=format,
filename=file.filename,
content_type=file.content_type,
)
text = await svc.transcribe(audio_bytes, voice_format=voice_format)
return AsrTranscribeOut(
text=text,
format=voice_format,
audio_bytes=len(audio_bytes),
)
@router.get("/sessions", response_model=SessionListResponse)

View File

@@ -245,3 +245,9 @@ class UserMemoirSnapshotOut(BaseModel):
user_id: str
chapters: list[MemoirChapterSnapOut]
stories: list[MemoirStorySnapOut]
class AsrTranscribeOut(BaseModel):
text: str
format: str = Field(description="提交给 ASR 的 voice_format")
audio_bytes: int = Field(description="上传音频字节数")