From db3b29290697b2f6b443a60a51ba1f8ce341cb2a Mon Sep 17 00:00:00 2001 From: HyxiaoGe <18889592303@163.com> Date: Mon, 12 Oct 2026 00:42:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B8=B8=E5=AE=A2=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E2=80=94=E2=80=94=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=AD=BE?= =?UTF-8?q?=E5=8F=91=E5=8C=BF=E5=90=8D=E4=BB=A4=E7=89=8C=EF=BC=8C=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=89=8D=E8=83=BD=E7=94=A8=E7=9A=84=E5=86=99=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=8B=92=E7=BB=9D=E6=B8=B8=E5=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/_deploy-api.yml | 4 + .../b7d2e4f6a813_add_user_is_guest.py | 26 ++ backend/app/api/auth.py | 67 +++++- backend/app/api/deps.py | 2 +- backend/app/api/files.py | 10 +- backend/app/api/knowledge_bases.py | 18 +- backend/app/core/config.py | 6 + backend/app/core/guest_identity.py | 111 +++++++++ backend/app/core/security.py | 24 ++ backend/app/db/models.py | 2 + backend/test/test_core_surface.py | 2 +- backend/test/test_guest_identity.py | 223 ++++++++++++++++++ backend/test/test_knowledge_bases.py | 2 +- ops/deploy/api-pull-and-restart.sh | 13 + ops/deploy/api-rollback-failed-deployment.sh | 11 + 15 files changed, 502 insertions(+), 19 deletions(-) create mode 100644 backend/alembic/versions/b7d2e4f6a813_add_user_is_guest.py create mode 100644 backend/app/core/guest_identity.py create mode 100644 backend/test/test_guest_identity.py diff --git a/.github/workflows/_deploy-api.yml b/.github/workflows/_deploy-api.yml index 61cfb400f..084c68443 100644 --- a/.github/workflows/_deploy-api.yml +++ b/.github/workflows/_deploy-api.yml @@ -219,6 +219,8 @@ jobs: DEPLOY_QWEATHER_DEVELOPER_ID: ${{ vars.QWEATHER_DEVELOPER_ID }} DEPLOY_QWEATHER_PRIVATE_KEY: ${{ secrets.QWEATHER_PRIVATE_KEY }} DEPLOY_FLYAI_API_KEY: ${{ secrets.FLYAI_API_KEY }} + DEPLOY_GUEST_TOKEN_SECRET: ${{ secrets.GUEST_TOKEN_SECRET }} + DEPLOY_GUEST_MODE_ENABLED: ${{ vars.GUEST_MODE_ENABLED || 'false' }} DEPLOY_LITELLM_MODEL_MANAGEMENT_ENABLED: ${{ vars.LITELLM_MODEL_MANAGEMENT_ENABLED || 'false' }} DEPLOY_KNOWLEDGE_BASE_ENABLED: ${{ vars.KNOWLEDGE_BASE_ENABLED || 'false' }} DEPLOY_KNOWLEDGE_MAX_FILE_SIZE: ${{ vars.KNOWLEDGE_MAX_FILE_SIZE || '10485760' }} @@ -322,6 +324,8 @@ jobs: DEPLOY_QWEATHER_DEVELOPER_ID: ${{ vars.QWEATHER_DEVELOPER_ID }} DEPLOY_QWEATHER_PRIVATE_KEY: ${{ secrets.QWEATHER_PRIVATE_KEY }} DEPLOY_FLYAI_API_KEY: ${{ secrets.FLYAI_API_KEY }} + DEPLOY_GUEST_TOKEN_SECRET: ${{ secrets.GUEST_TOKEN_SECRET }} + DEPLOY_GUEST_MODE_ENABLED: ${{ vars.GUEST_MODE_ENABLED || 'false' }} ROLLBACK_LITELLM_MODEL_MANAGEMENT_ENABLED: ${{ steps.capture_rollback_target.outputs.model_management_enabled }} run: ops/deploy/api-rollback-failed-deployment.sh - name: Push CI/CD metrics diff --git a/backend/alembic/versions/b7d2e4f6a813_add_user_is_guest.py b/backend/alembic/versions/b7d2e4f6a813_add_user_is_guest.py new file mode 100644 index 000000000..a51341d06 --- /dev/null +++ b/backend/alembic/versions/b7d2e4f6a813_add_user_is_guest.py @@ -0,0 +1,26 @@ +"""用户表增加 is_guest,区分游客身份与登录用户。 + +Revision ID: b7d2e4f6a813 +Revises: a3e9f7b2c514 +Create Date: 2026-10-12 Asia/Shanghai +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "b7d2e4f6a813" +down_revision = "a3e9f7b2c514" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "users", + sa.Column("is_guest", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + +def downgrade(): + op.drop_column("users", "is_guest") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index f4cf54a9e..955ed2a2b 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,15 +1,30 @@ +import logging +import uuid + from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from sqlalchemy.orm import Session from app.api.deps import get_user_repo from app.core import security from app.core.avatar_proxy import AvatarProxyError, fetch_avatar +from app.core.guest_identity import ( + client_ip, + guest_mode_available, + ip_digest, + issue_guest_token, + reserve_new_guest_slot, +) +from app.db.database import get_db from app.db.models import User as UserModel from app.db.repositories import UserRepository from app.schemas.auth import UserSettingsUpdate -from app.schemas.response import success +from app.schemas.response import ApiException, success +logger = logging.getLogger(__name__) router = APIRouter() +GUEST_NICKNAME = "游客" + @router.get("/avatar") def proxy_avatar(url: str = Query(..., min_length=1)): @@ -38,15 +53,63 @@ async def read_current_user(request: Request, current_user: UserModel = Depends( "avatar": current_user.avatar, "system_prompt": current_user.system_prompt or "", "is_superuser": bool(current_user.is_superuser), + "is_guest": bool(current_user.is_guest), } return success(data=user_data, request_id=request.state.request_id) +@router.post("/guest") +def create_guest_identity(request: Request, db: Session = Depends(get_db)): + """未登录用户领取游客身份。每次调用都新建一个游客,前端拿到令牌后长期保存复用。""" + if not guest_mode_available(): + raise ApiException("GUEST_MODE_DISABLED", "暂未开放免登录使用,请登录后使用", status_code=403) + ip, ip_source = client_ip(request) + try: + allowed = reserve_new_guest_slot(ip) + except Exception: + logger.warning("guest_identity_rate_limit_unavailable", exc_info=True) + raise ApiException("GUEST_UNAVAILABLE", "暂时无法免登录使用,请稍后再试或登录", status_code=503) + if not allowed: + logger.info("guest_identity_rejected reason=ip_daily_limit ip=%s source=%s", ip_digest(ip), ip_source) + raise ApiException("GUEST_LIMIT_REACHED", "今天免登录名额已用完,登录后继续使用", status_code=429) + + user_id = str(uuid.uuid4()) + user = UserRepository(db).create( + { + "id": user_id, + "username": f"guest-{user_id.replace('-', '')[:16]}", + "nickname": GUEST_NICKNAME, + "is_guest": True, + } + ) + db.commit() + db.refresh(user) + token, expires_at = issue_guest_token(user.id) + logger.info("guest_identity_created user=%s ip=%s source=%s", user.id, ip_digest(ip), ip_source) + return success( + data={ + "access_token": token, + "token_type": "bearer", + "expires_at": expires_at, + "user": { + "id": user.id, + "username": user.username, + "nickname": user.nickname, + "avatar": None, + "system_prompt": "", + "is_superuser": False, + "is_guest": True, + }, + }, + request_id=request.state.request_id, + ) + + @router.patch("/me") async def update_current_user( body: UserSettingsUpdate, request: Request, - current_user: UserModel = Depends(security.get_current_user), + current_user: UserModel = Depends(security.require_registered_user), user_repo: UserRepository = Depends(get_user_repo), ): """更新当前用户的个性化设置(system_prompt)""" diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 526381c89..3d824c3af 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session from app.core.config import settings -from app.core.security import get_current_user # noqa: F401 — re-export +from app.core.security import get_current_user, require_registered_user # noqa: F401 — re-export from app.db.admin_audit_repository import AdminAuditRepository from app.db.database import get_db # noqa: F401 — re-export from app.db.mcp_server_repository import McpServerRepository diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 30ed406f8..537d20173 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -5,7 +5,7 @@ from fastapi.responses import Response from sqlalchemy.orm import Session -from app.api.deps import get_current_user, get_db, get_file_service +from app.api.deps import get_current_user, get_db, get_file_service, require_registered_user from app.core.file_token import verify_file_token from app.core.revocation import extract_session_id, is_session_access_revoked, is_user_access_revoked from app.core.security import jwt_validator @@ -54,7 +54,7 @@ async def upload_files( model: str = Form(...), conversation_id: str = Form(...), files: List[UploadFile] = File(...), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_registered_user), file_service: FileService = Depends(get_file_service), ): """上传文件到指定对话""" @@ -69,7 +69,7 @@ async def upload_files( async def init_direct_upload( payload: DirectUploadInitRequest, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_registered_user), file_service: FileService = Depends(get_file_service), ): """初始化浏览器直传上传。""" @@ -95,7 +95,7 @@ async def init_direct_upload( async def complete_direct_upload( payload: DirectUploadCompleteRequest, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_registered_user), file_service: FileService = Depends(get_file_service), ): """确认浏览器直传完成,并接入文件处理管线。""" @@ -213,7 +213,7 @@ def get_file_status( async def delete_file( file_id: str, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_registered_user), file_service: FileService = Depends(get_file_service), ): """删除文件""" diff --git a/backend/app/api/knowledge_bases.py b/backend/app/api/knowledge_bases.py index a964a90cb..452361707 100644 --- a/backend/app/api/knowledge_bases.py +++ b/backend/app/api/knowledge_bases.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, File, Path, Query, Request, UploadFile -from app.api.deps import get_current_user, get_knowledge_service +from app.api.deps import get_current_user, get_knowledge_service, require_registered_user from app.db.models import User from app.schemas.knowledge import ( KnowledgeBaseCreate, @@ -46,7 +46,7 @@ def create_knowledge_base( payload: KnowledgeBaseCreate, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_registered_user), service: KnowledgeService = Depends(get_knowledge_service), ): row = service.create_knowledge_base(current_user.id, payload) @@ -89,7 +89,7 @@ def update_knowledge_base( knowledge_base_id: KnowledgeResourceId, payload: KnowledgeBaseUpdate, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_registered_user), service: KnowledgeService = Depends(get_knowledge_service), ): row = service.update_knowledge_base(current_user.id, knowledge_base_id, payload) @@ -105,7 +105,7 @@ def update_knowledge_base( def delete_knowledge_base( knowledge_base_id: KnowledgeResourceId, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_registered_user), service: KnowledgeService = Depends(get_knowledge_service), ): task = service.delete_knowledge_base(current_user.id, knowledge_base_id) @@ -122,7 +122,7 @@ async def upload_knowledge_document( knowledge_base_id: KnowledgeResourceId, request: Request, file: UploadFile = File(...), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_registered_user), service: KnowledgeService = Depends(get_knowledge_service), ): result = await service.upload_document(current_user.id, knowledge_base_id, file) @@ -201,7 +201,7 @@ def delete_knowledge_document( knowledge_base_id: KnowledgeResourceId, document_id: KnowledgeResourceId, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_registered_user), service: KnowledgeService = Depends(get_knowledge_service), ): task = service.delete_document(current_user.id, knowledge_base_id, document_id) @@ -218,7 +218,7 @@ def retry_knowledge_document( knowledge_base_id: KnowledgeResourceId, document_id: KnowledgeResourceId, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_registered_user), service: KnowledgeService = Depends(get_knowledge_service), ): task = service.retry_document(current_user.id, knowledge_base_id, document_id) @@ -235,7 +235,7 @@ def rebuild_knowledge_document( knowledge_base_id: KnowledgeResourceId, document_id: KnowledgeResourceId, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_registered_user), service: KnowledgeService = Depends(get_knowledge_service), ): task = service.rebuild_document(current_user.id, knowledge_base_id, document_id) @@ -250,7 +250,7 @@ def rebuild_knowledge_document( async def search_knowledge_bases( payload: KnowledgeRetrievalRequest, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_registered_user), service: KnowledgeService = Depends(get_knowledge_service), ): result = await service.retrieve(current_user.id, payload) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9b650c7b4..1cc371a01 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -78,6 +78,12 @@ class Settings(BaseSettings): OSS_BUCKET: str = os.getenv("OSS_BUCKET", "") OSS_USE_SSL: bool = os.getenv("OSS_USE_SSL", "true").lower() == "true" + # 游客模式:未登录用户以服务端签发的游客身份使用部分功能。密钥未配置时入口保持关闭。 + GUEST_MODE_ENABLED: bool = os.getenv("GUEST_MODE_ENABLED", "false").lower() == "true" + GUEST_TOKEN_SECRET: str = os.getenv("GUEST_TOKEN_SECRET", "") + GUEST_TOKEN_TTL_DAYS: int = int(os.getenv("GUEST_TOKEN_TTL_DAYS", "365")) + GUEST_NEW_IDENTITIES_PER_IP_PER_DAY: int = int(os.getenv("GUEST_NEW_IDENTITIES_PER_IP_PER_DAY", "5")) + # 知识库 v1:API、独立 Worker、Embedding 与 Milvus 均通过配置显式启用。 KNOWLEDGE_BASE_ENABLED: bool = os.getenv("KNOWLEDGE_BASE_ENABLED", "false").lower() == "true" KNOWLEDGE_MAX_BASES_PER_USER: int = int(os.getenv("KNOWLEDGE_MAX_BASES_PER_USER", "50")) diff --git a/backend/app/core/guest_identity.py b/backend/app/core/guest_identity.py new file mode 100644 index 000000000..9d49d75b5 --- /dev/null +++ b/backend/app/core/guest_identity.py @@ -0,0 +1,111 @@ +"""游客身份:Fusion 自己签发的匿名令牌。 + +未登录用户先领取一个游客身份(服务端创建 is_guest 用户),之后像登录用户一样以 +Bearer 令牌访问,会话照常归属这个用户,刷新或重开页面都还在。游客令牌用独立密钥 +HS256 签名,签发方固定为 GUEST_ISSUER,与统一认证服务的 RS256 令牌互不混用。 +""" + +import hashlib +import logging +import time +from datetime import datetime, timedelta, timezone + +import jwt +from fastapi import Request + +from app.core.config import settings +from app.core.revocation import get_redis + +logger = logging.getLogger(__name__) + +GUEST_ISSUER = "fusion-guest" +GUEST_TOKEN_TYPE = "guest" +_GUEST_ALGORITHM = "HS256" +_NEW_GUEST_KEY_PREFIX = "guest_new" +_CHINA_TZ = timezone(timedelta(hours=8)) + + +class GuestTokenError(Exception): + pass + + +def guest_mode_available() -> bool: + return settings.GUEST_MODE_ENABLED and bool(settings.GUEST_TOKEN_SECRET) + + +def issue_guest_token(user_id: str, *, now: float | None = None) -> tuple[str, int]: + issued_at = int(now if now is not None else time.time()) + expires_at = issued_at + settings.GUEST_TOKEN_TTL_DAYS * 24 * 3600 + token = jwt.encode( + { + "iss": GUEST_ISSUER, + "sub": user_id, + "typ": GUEST_TOKEN_TYPE, + "iat": issued_at, + "exp": expires_at, + }, + settings.GUEST_TOKEN_SECRET, + algorithm=_GUEST_ALGORITHM, + ) + return token, expires_at + + +def is_guest_token(token: str) -> bool: + """只看未验签的签发方来分流;真正的信任来自 verify_guest_token 的验签。""" + try: + claims = jwt.decode(token, options={"verify_signature": False}) + except jwt.PyJWTError: + return False + return claims.get("iss") == GUEST_ISSUER + + +def verify_guest_token(token: str) -> str: + if not guest_mode_available(): + raise GuestTokenError("guest mode disabled") + try: + claims = jwt.decode( + token, + settings.GUEST_TOKEN_SECRET, + algorithms=[_GUEST_ALGORITHM], + issuer=GUEST_ISSUER, + options={"require": ["exp", "iat", "sub", "iss"]}, + ) + except jwt.PyJWTError as exc: + raise GuestTokenError(str(exc)) from exc + subject = claims.get("sub") + if claims.get("typ") != GUEST_TOKEN_TYPE or not isinstance(subject, str) or not subject: + raise GuestTokenError("invalid guest claims") + return subject + + +def client_ip(request: Request) -> tuple[str, str]: + """返回 (IP, 来源)。外层经 Cloudflare 与前端代理转发,优先取 Cloudflare 给出的真实地址。""" + for header in ("cf-connecting-ip", "x-real-ip"): + value = (request.headers.get(header) or "").strip() + if value: + return value, header + forwarded = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip() + if forwarded: + return forwarded, "x-forwarded-for" + host = request.client.host if request.client else "" + return host or "unknown", "peer" + + +def ip_digest(ip: str) -> str: + """限流键与日志只记 IP 摘要,不落原始地址。""" + return hashlib.sha256(ip.encode()).hexdigest()[:16] + + +def china_day(now: float | None = None) -> str: + moment = datetime.fromtimestamp(now if now is not None else time.time(), tz=_CHINA_TZ) + return moment.strftime("%Y%m%d") + + +def reserve_new_guest_slot(ip: str, *, now: float | None = None) -> bool: + """同一 IP 每天(北京时间)新建游客身份的次数上限。Redis 不可用时拒绝新建,登录用户不受影响。""" + key = f"{_NEW_GUEST_KEY_PREFIX}:{china_day(now)}:{ip_digest(ip)}" + client = get_redis() + count = client.incr(key) + if count == 1: + client.expire(key, 2 * 24 * 3600) + return count <= settings.GUEST_NEW_IDENTITIES_PER_IP_PER_DAY diff --git a/backend/app/core/security.py b/backend/app/core/security.py index e222f55d9..c527d95b4 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -13,10 +13,12 @@ from sqlalchemy.orm import Session from app.core.config import settings +from app.core.guest_identity import GuestTokenError, is_guest_token, verify_guest_token from app.core.revocation import extract_session_id, is_session_access_revoked, is_user_access_revoked from app.db.database import get_db from app.db.models import User from app.db.repositories import SocialAccountRepository, UserRepository +from app.schemas.response import ApiException logger = logging.getLogger(__name__) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/token") @@ -279,6 +281,8 @@ def get_current_user(db: Session = Depends(get_db), token: str = Depends(oauth2_ detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) + if is_guest_token(token): + return _resolve_guest_user(db, token, credentials_exception) try: auth_user = jwt_validator.verify(token) except (InvalidTokenError, httpx.HTTPError) as exc: @@ -308,3 +312,23 @@ def get_current_user(db: Session = Depends(get_db), token: str = Depends(oauth2_ if user is None: raise credentials_exception return user + + +def _resolve_guest_user(db: Session, token: str, credentials_exception: HTTPException) -> User: + """游客令牌由 Fusion 自己签发,不经统一认证服务,也不参与单点登出。""" + try: + subject = verify_guest_token(token) + except GuestTokenError as exc: + logger.info("Guest token rejected: %s", exc) + raise credentials_exception + user = UserRepository(db).get(subject) + if user is None or not user.is_guest: + raise credentials_exception + return user + + +def require_registered_user(current_user: User = Depends(get_current_user)) -> User: + """登录才能用的功能(上传文件、知识库、个人设置等)挂这个依赖,游客一律拒绝。""" + if current_user.is_guest: + raise ApiException("LOGIN_REQUIRED", "登录后可使用此功能", status_code=403) + return current_user diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 993899240..c46432e9c 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -62,6 +62,8 @@ class User(Base): email = Column(String, unique=True, index=True, nullable=True) system_prompt = Column(Text, nullable=False, default="", server_default="") is_superuser = Column(Boolean, nullable=False, server_default="false", default=False) + # 游客:服务端签发的匿名身份,没有邮箱和第三方账号,只能使用游客等级开放的功能。 + is_guest = Column(Boolean, nullable=False, server_default="false", default=False) created_at = Column(DateTime, default=get_china_time) updated_at = Column(DateTime, default=get_china_time, onupdate=get_china_time) diff --git a/backend/test/test_core_surface.py b/backend/test/test_core_surface.py index 3b8ac7c5c..6de4918d6 100644 --- a/backend/test/test_core_surface.py +++ b/backend/test/test_core_surface.py @@ -27,7 +27,7 @@ def setUpClass(cls): cls.main = main cls.client = TestClient(main.app) - cls.fake_user = SimpleNamespace(id="user-123") + cls.fake_user = SimpleNamespace(id="user-123", is_guest=False) # 从路由依赖中提取实际函数引用,避免模块重导入导致的函数对象不一致 cls._route_deps = {} diff --git a/backend/test/test_guest_identity.py b/backend/test/test_guest_identity.py new file mode 100644 index 000000000..c7a08f395 --- /dev/null +++ b/backend/test/test_guest_identity.py @@ -0,0 +1,223 @@ +import importlib +import os +import sys +import time +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import jwt +from fastapi import HTTPException +from fastapi.testclient import TestClient +from starlette.requests import Request + +os.environ.setdefault("DATABASE_URL", "sqlite:///./fusion-test.db") +os.environ.setdefault("AUTH_SERVICE_BASE_URL", "http://auth.example:8100") +os.environ.setdefault("AUTH_SERVICE_CLIENT_ID", "fusion-client") +os.environ.setdefault("AUTH_SERVICE_JWKS_URL", "http://auth.example:8100/.well-known/jwks.json") + +from app.core import guest_identity, security # noqa: E402 +from app.core.config import settings # noqa: E402 +from app.schemas.response import ApiException # noqa: E402 + +SECRET = "test-guest-secret-0123456789abcdef" + + +def _guest_settings(enabled=True, secret=SECRET): + return patch.multiple(settings, GUEST_MODE_ENABLED=enabled, GUEST_TOKEN_SECRET=secret) + + +class FakeRedis: + def __init__(self): + self.values = {} + self.expire_calls = [] + + def incr(self, key): + self.values[key] = self.values.get(key, 0) + 1 + return self.values[key] + + def expire(self, key, seconds): + self.expire_calls.append((key, seconds)) + + +def _request(headers=None, host="10.0.0.9"): + raw = [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()] + return Request({"type": "http", "headers": raw, "client": (host, 1234)}) + + +class GuestTokenTests(unittest.TestCase): + def test_issued_token_verifies_to_same_user(self): + with _guest_settings(): + token, expires_at = guest_identity.issue_guest_token("guest-1") + self.assertTrue(guest_identity.is_guest_token(token)) + self.assertEqual(guest_identity.verify_guest_token(token), "guest-1") + self.assertGreater(expires_at, time.time() + 300 * 24 * 3600) + + def test_token_signed_with_other_secret_is_rejected(self): + forged = jwt.encode( + {"iss": "fusion-guest", "sub": "victim", "typ": "guest", "iat": int(time.time()), "exp": int(time.time()) + 60}, + "a_secret_key", + algorithm="HS256", + ) + with _guest_settings(): + self.assertTrue(guest_identity.is_guest_token(forged)) + with self.assertRaises(guest_identity.GuestTokenError): + guest_identity.verify_guest_token(forged) + + def test_expired_token_is_rejected(self): + with _guest_settings(): + token, _ = guest_identity.issue_guest_token("guest-1", now=time.time() - 400 * 24 * 3600) + with self.assertRaises(guest_identity.GuestTokenError): + guest_identity.verify_guest_token(token) + + def test_disabled_guest_mode_rejects_valid_token(self): + with _guest_settings(): + token, _ = guest_identity.issue_guest_token("guest-1") + with _guest_settings(enabled=False): + with self.assertRaises(guest_identity.GuestTokenError): + guest_identity.verify_guest_token(token) + with _guest_settings(secret=""): + self.assertFalse(guest_identity.guest_mode_available()) + + def test_auth_service_token_is_not_treated_as_guest(self): + other = jwt.encode({"iss": "http://auth.example:8100", "sub": "u"}, "x", algorithm="HS256") + self.assertFalse(guest_identity.is_guest_token(other)) + self.assertFalse(guest_identity.is_guest_token("not-a-jwt")) + + +class GuestRateLimitTests(unittest.TestCase): + def test_new_guest_slots_are_limited_per_ip_per_day(self): + fake = FakeRedis() + with ( + patch.object(guest_identity, "get_redis", return_value=fake), + patch.object(settings, "GUEST_NEW_IDENTITIES_PER_IP_PER_DAY", 5), + ): + results = [guest_identity.reserve_new_guest_slot("1.2.3.4", now=1_000_000) for _ in range(6)] + other_ip = guest_identity.reserve_new_guest_slot("5.6.7.8", now=1_000_000) + self.assertEqual(results, [True] * 5 + [False]) + self.assertTrue(other_ip) + self.assertEqual(len(fake.expire_calls), 2) + self.assertNotIn("1.2.3.4", next(iter(fake.values))) + + def test_client_ip_prefers_cloudflare_header(self): + request = _request({"CF-Connecting-IP": "1.1.1.1", "X-Forwarded-For": "2.2.2.2, 3.3.3.3"}) + self.assertEqual(guest_identity.client_ip(request), ("1.1.1.1", "cf-connecting-ip")) + request = _request({"X-Forwarded-For": "2.2.2.2, 3.3.3.3"}) + self.assertEqual(guest_identity.client_ip(request), ("2.2.2.2", "x-forwarded-for")) + self.assertEqual(guest_identity.client_ip(_request()), ("10.0.0.9", "peer")) + + +class GuestAuthenticationTests(unittest.TestCase): + def _resolve(self, token, user): + repo = MagicMock() + repo.get.return_value = user + with ( + _guest_settings(), + patch("app.core.security.UserRepository", return_value=repo), + patch.object(security.jwt_validator, "verify") as auth_verify, + ): + try: + return security.get_current_user(db=MagicMock(), token=token) + finally: + auth_verify.assert_not_called() + + def test_guest_token_resolves_guest_user_without_auth_service(self): + guest = SimpleNamespace(id="guest-1", is_guest=True) + with _guest_settings(): + token, _ = guest_identity.issue_guest_token("guest-1") + self.assertIs(self._resolve(token, guest), guest) + + def test_guest_token_cannot_impersonate_registered_user(self): + registered = SimpleNamespace(id="user-1", is_guest=False) + with _guest_settings(): + token, _ = guest_identity.issue_guest_token("user-1") + with self.assertRaises(HTTPException) as ctx: + self._resolve(token, registered) + self.assertEqual(ctx.exception.status_code, 401) + + def test_require_registered_user_rejects_guest(self): + with self.assertRaises(ApiException) as ctx: + security.require_registered_user(SimpleNamespace(is_guest=True)) + self.assertEqual((ctx.exception.code, ctx.exception.status_code), ("LOGIN_REQUIRED", 403)) + user = SimpleNamespace(is_guest=False) + self.assertIs(security.require_registered_user(user), user) + + +class GuestApiTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + sys.modules.pop("main", None) + cls.main = importlib.import_module("main") + cls.client = TestClient(cls.main.app) + cls.deps = {} + for route in cls.main.app.routes: + pending = list(getattr(getattr(route, "dependant", None), "dependencies", [])) + while pending: + dep = pending.pop() + name = getattr(dep.call, "__qualname__", None) + if name: + cls.deps.setdefault(name, dep.call) + pending.extend(dep.dependencies) + + def tearDown(self): + self.main.app.dependency_overrides.clear() + + def _override_db(self, db): + def override(): + yield db + + self.main.app.dependency_overrides[self.deps["get_db"]] = override + + def test_create_guest_returns_token_for_new_guest_user(self): + db = MagicMock() + self._override_db(db) + created = {} + + def create(values): + created.update(values) + return SimpleNamespace(**values) + + repo = MagicMock() + repo.create.side_effect = create + with ( + _guest_settings(), + patch("app.api.auth.UserRepository", return_value=repo), + patch("app.api.auth.reserve_new_guest_slot", return_value=True), + ): + response = self.client.post("/api/auth/guest", headers={"CF-Connecting-IP": "1.1.1.1"}) + self.assertEqual(response.status_code, 200) + data = response.json()["data"] + self.assertEqual(guest_identity.verify_guest_token(data["access_token"]), created["id"]) + self.assertTrue(created["is_guest"]) + self.assertTrue(data["user"]["is_guest"]) + db.commit.assert_called_once() + + def test_create_guest_when_disabled_or_limited(self): + self._override_db(MagicMock()) + with _guest_settings(enabled=False): + response = self.client.post("/api/auth/guest") + self.assertEqual((response.status_code, response.json()["code"]), (403, "GUEST_MODE_DISABLED")) + with _guest_settings(), patch("app.api.auth.reserve_new_guest_slot", return_value=False): + response = self.client.post("/api/auth/guest") + self.assertEqual((response.status_code, response.json()["code"]), (429, "GUEST_LIMIT_REACHED")) + with _guest_settings(), patch("app.api.auth.reserve_new_guest_slot", side_effect=ConnectionError): + response = self.client.post("/api/auth/guest") + self.assertEqual((response.status_code, response.json()["code"]), (503, "GUEST_UNAVAILABLE")) + + def test_guest_is_blocked_from_login_only_writes(self): + self._override_db(MagicMock()) + self.main.app.dependency_overrides[self.deps["get_current_user"]] = lambda: SimpleNamespace( + id="guest-1", is_guest=True + ) + for method, path, kwargs in [ + ("patch", "/api/auth/me", {"json": {"system_prompt": "x"}}), + ("post", "/api/files/upload/init", {"json": {}}), + ("post", "/api/knowledge-bases/", {"json": {"name": "kb"}}), + ]: + response = getattr(self.client, method)(path, **kwargs) + self.assertEqual(response.status_code, 403, path) + self.assertEqual(response.json()["code"], "LOGIN_REQUIRED", path) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/test/test_knowledge_bases.py b/backend/test/test_knowledge_bases.py index 7789c1384..15ab85a40 100644 --- a/backend/test/test_knowledge_bases.py +++ b/backend/test/test_knowledge_bases.py @@ -182,7 +182,7 @@ def setUp(self): from app.api.deps import get_current_user, get_knowledge_service self.service = FakeKnowledgeService() - self.main.app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id="user-1") + self.main.app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id="user-1", is_guest=False) self.main.app.dependency_overrides[get_knowledge_service] = lambda: self.service def tearDown(self): diff --git a/ops/deploy/api-pull-and-restart.sh b/ops/deploy/api-pull-and-restart.sh index 2153e9b45..503e07610 100755 --- a/ops/deploy/api-pull-and-restart.sh +++ b/ops/deploy/api-pull-and-restart.sh @@ -302,6 +302,17 @@ if [ -z "${FLYAI_API_KEY}" ]; then exit 1 fi export FLYAI_ADAPTER_TOKEN="$(python3 -c 'import hashlib,hmac,os; print(hmac.new(os.environ["FLYAI_API_KEY"].encode(), b"fusion-deploy-flyai-adapter-v1", hashlib.sha256).hexdigest(), end="")')" +# 游客模式:令牌密钥只来自 GitHub Secret;未配置时游客入口保持关闭,不影响登录用户。 +export GUEST_TOKEN_SECRET="${DEPLOY_GUEST_TOKEN_SECRET:-}" +export GUEST_MODE_ENABLED="$(printf '%s' "${DEPLOY_GUEST_MODE_ENABLED:-false}" | tr '[:upper:]' '[:lower:]')" +case "${GUEST_MODE_ENABLED}" in + true|false) ;; + *) echo "GUEST_MODE_ENABLED 必须为 true 或 false"; exit 1 ;; +esac +if [ "${GUEST_MODE_ENABLED}" = "true" ] && [ -z "${GUEST_TOKEN_SECRET}" ]; then + echo "已开启游客模式但未配置 GUEST_TOKEN_SECRET" + exit 1 +fi n=0 until docker pull "${DEPLOY_API_IMAGE}"; do n=$((n+1)) @@ -490,6 +501,8 @@ services: - FLYAI_ADAPTER_BASE_URL=http://flyai-adapter:8080 - FLYAI_ADAPTER_TOKEN=${FLYAI_ADAPTER_TOKEN} - FLYAI_TRAVEL_TOOL_TIMEOUT_SECONDS=20 + - GUEST_MODE_ENABLED=${GUEST_MODE_ENABLED:-false} + - GUEST_TOKEN_SECRET=${GUEST_TOKEN_SECRET:-} - FLYAI_TRAVEL_MAX_TOOL_CALLS_PER_RUN=4 - KNOWLEDGE_BASE_ENABLED=${KNOWLEDGE_BASE_ENABLED} - KNOWLEDGE_MAX_BASES_PER_USER=${KNOWLEDGE_MAX_BASES_PER_USER} diff --git a/ops/deploy/api-rollback-failed-deployment.sh b/ops/deploy/api-rollback-failed-deployment.sh index 43ff8d2c4..803d861d1 100755 --- a/ops/deploy/api-rollback-failed-deployment.sh +++ b/ops/deploy/api-rollback-failed-deployment.sh @@ -142,6 +142,17 @@ if [ -z "${FLYAI_API_KEY}" ]; then exit 1 fi export FLYAI_ADAPTER_TOKEN="$(python3 -c 'import hashlib,hmac,os; print(hmac.new(os.environ["FLYAI_API_KEY"].encode(), b"fusion-deploy-flyai-adapter-v1", hashlib.sha256).hexdigest(), end="")')" +# 游客模式:令牌密钥只来自 GitHub Secret;未配置时游客入口保持关闭,不影响登录用户。 +export GUEST_TOKEN_SECRET="${DEPLOY_GUEST_TOKEN_SECRET:-}" +export GUEST_MODE_ENABLED="$(printf '%s' "${DEPLOY_GUEST_MODE_ENABLED:-false}" | tr '[:upper:]' '[:lower:]')" +case "${GUEST_MODE_ENABLED}" in + true|false) ;; + *) echo "GUEST_MODE_ENABLED 必须为 true 或 false"; exit 1 ;; +esac +if [ "${GUEST_MODE_ENABLED}" = "true" ] && [ -z "${GUEST_TOKEN_SECRET}" ]; then + echo "已开启游客模式但未配置 GUEST_TOKEN_SECRET" + exit 1 +fi export AUTH_SERVICE_INTERNAL_BASE_URL="http://auth-service:8100" export DEPLOY_API_IMAGE="${ROLLBACK_API_IMAGE_REF}" export DEPLOY_ADAPTER_IMAGE="${ROLLBACK_ADAPTER_IMAGE_REF}" From 4cd20e08c3eb8317f8b0c7dadfe51df83a3bdddf Mon Sep 17 00:00:00 2001 From: HyxiaoGe <18889592303@163.com> Date: Mon, 12 Oct 2026 00:49:29 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E6=B8=B8=E5=AE=A2=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E6=94=B9=E7=94=B1=20UserRepository=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=8C=E5=90=8C=E6=AD=A5=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=AE=A1=E9=98=85=E6=8C=87=E7=BA=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- backend/app/api/auth.py | 16 ++----------- backend/app/db/repositories.py | 14 +++++++++++ backend/test/test_guest_identity.py | 24 +++++++++---------- .../fixtures/deploy-script-contracts.tsv | 4 ++-- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 955ed2a2b..9d4e191e9 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -2,7 +2,6 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response -from sqlalchemy.orm import Session from app.api.deps import get_user_repo from app.core import security @@ -14,7 +13,6 @@ issue_guest_token, reserve_new_guest_slot, ) -from app.db.database import get_db from app.db.models import User as UserModel from app.db.repositories import UserRepository from app.schemas.auth import UserSettingsUpdate @@ -59,7 +57,7 @@ async def read_current_user(request: Request, current_user: UserModel = Depends( @router.post("/guest") -def create_guest_identity(request: Request, db: Session = Depends(get_db)): +def create_guest_identity(request: Request, user_repo: UserRepository = Depends(get_user_repo)): """未登录用户领取游客身份。每次调用都新建一个游客,前端拿到令牌后长期保存复用。""" if not guest_mode_available(): raise ApiException("GUEST_MODE_DISABLED", "暂未开放免登录使用,请登录后使用", status_code=403) @@ -73,17 +71,7 @@ def create_guest_identity(request: Request, db: Session = Depends(get_db)): logger.info("guest_identity_rejected reason=ip_daily_limit ip=%s source=%s", ip_digest(ip), ip_source) raise ApiException("GUEST_LIMIT_REACHED", "今天免登录名额已用完,登录后继续使用", status_code=429) - user_id = str(uuid.uuid4()) - user = UserRepository(db).create( - { - "id": user_id, - "username": f"guest-{user_id.replace('-', '')[:16]}", - "nickname": GUEST_NICKNAME, - "is_guest": True, - } - ) - db.commit() - db.refresh(user) + user = user_repo.create_guest(str(uuid.uuid4()), GUEST_NICKNAME) token, expires_at = issue_guest_token(user.id) logger.info("guest_identity_created user=%s ip=%s source=%s", user.id, ip_digest(ip), ip_source) return success( diff --git a/backend/app/db/repositories.py b/backend/app/db/repositories.py index 6639969ad..8b2e5aa4d 100644 --- a/backend/app/db/repositories.py +++ b/backend/app/db/repositories.py @@ -67,6 +67,20 @@ def create(self, obj_in: Dict[str, Any]) -> UserModel: self.db.add(db_obj) return db_obj + def create_guest(self, user_id: str, nickname: str) -> UserModel: + """新建游客用户并提交;用户名带 ID 前缀保证唯一。""" + user = self.create( + { + "id": user_id, + "username": f"guest-{user_id.replace('-', '')[:16]}", + "nickname": nickname, + "is_guest": True, + } + ) + self.db.commit() + self.db.refresh(user) + return user + def update_system_prompt(self, user: UserModel, system_prompt: str) -> UserModel: user.system_prompt = system_prompt self.db.commit() diff --git a/backend/test/test_guest_identity.py b/backend/test/test_guest_identity.py index c7a08f395..2d263e6f6 100644 --- a/backend/test/test_guest_identity.py +++ b/backend/test/test_guest_identity.py @@ -55,7 +55,13 @@ def test_issued_token_verifies_to_same_user(self): def test_token_signed_with_other_secret_is_rejected(self): forged = jwt.encode( - {"iss": "fusion-guest", "sub": "victim", "typ": "guest", "iat": int(time.time()), "exp": int(time.time()) + 60}, + { + "iss": "fusion-guest", + "sub": "victim", + "typ": "guest", + "iat": int(time.time()), + "exp": int(time.time()) + 60, + }, "a_secret_key", algorithm="HS256", ) @@ -171,24 +177,18 @@ def override(): def test_create_guest_returns_token_for_new_guest_user(self): db = MagicMock() self._override_db(db) - created = {} - - def create(values): - created.update(values) - return SimpleNamespace(**values) - - repo = MagicMock() - repo.create.side_effect = create with ( _guest_settings(), - patch("app.api.auth.UserRepository", return_value=repo), patch("app.api.auth.reserve_new_guest_slot", return_value=True), ): response = self.client.post("/api/auth/guest", headers={"CF-Connecting-IP": "1.1.1.1"}) self.assertEqual(response.status_code, 200) data = response.json()["data"] - self.assertEqual(guest_identity.verify_guest_token(data["access_token"]), created["id"]) - self.assertTrue(created["is_guest"]) + self.assertEqual(guest_identity.verify_guest_token(data["access_token"]), data["user"]["id"]) + created = db.add.call_args.args[0] + self.assertEqual(created.id, data["user"]["id"]) + self.assertTrue(created.is_guest) + self.assertTrue(created.username.startswith("guest-")) self.assertTrue(data["user"]["is_guest"]) db.commit.assert_called_once() diff --git a/ops/deploy/tests/fixtures/deploy-script-contracts.tsv b/ops/deploy/tests/fixtures/deploy-script-contracts.tsv index 091bf4f74..4af10b2c5 100644 --- a/ops/deploy/tests/fixtures/deploy-script-contracts.tsv +++ b/ops/deploy/tests/fixtures/deploy-script-contracts.tsv @@ -9,14 +9,14 @@ workflow step script body_sha256 .github/workflows/_deploy-api.yml Bootstrap current API release ledger ops/deploy/api-bootstrap-current-release-ledger.sh d320ba8f64c98d8c0141f9669c4a7959d541678cbc54bdb8a5661be65adced5f .github/workflows/_deploy-api.yml Resolve API deployment digest ops/deploy/api-resolve-deployment-digest.sh b457203ca500d9248d0afbacc8e01ba7f6bf6d29b7e05a923809be4f0778b79c .github/workflows/_deploy-api.yml Apply alembic migrations ops/deploy/api-apply-alembic-migrations.sh d551f00a445e352bcedb1b1633f3b03342824e397bdfef1604a6212028203ac3 -.github/workflows/_deploy-api.yml Pull and restart fusion-api ops/deploy/api-pull-and-restart.sh c62f7ced3c3707936c024958c90979277c3ba2661247ad84259fcf97dde107bf +.github/workflows/_deploy-api.yml Pull and restart fusion-api ops/deploy/api-pull-and-restart.sh 417c9aa14cda902ff349886ac5ca144fd7a2da7fafe39b6b57a7dffbd68fd8f3 .github/workflows/_deploy-api.yml Verify deployed image identity ops/deploy/api-verify-image-identity.sh 0ba67f2cc67cac14f949aefd562af300bb6c2a7546c3a3963934b4e3d67c1ec7 .github/workflows/_deploy-api.yml Verify health ops/deploy/api-verify-health.sh c705964aab4051089dc18439468d6c53a632bb995f6ed4e012252bddbbbc8e12 .github/workflows/_deploy-api.yml Install LiteLLM cost sync ops/deploy/api-install-litellm-cost-sync.sh 53c29a4d58853c6a86e8e235785322afd6f8ee154def9d61c5e61ccbff80c827 .github/workflows/_deploy-api.yml Run deployment smoke ops/deploy/api-run-deployment-smoke.sh 9ea72029a93a6b35299c87b13a6ce51f29778171cbf889dd249a9ad15fd6b3cb .github/workflows/_deploy-api.yml Preserve API rollback images ops/deploy/api-preserve-rollback-images.sh 17845eaa17a21ca0e7250eebeaec107e92c642b8202354ffeb816b2a86779d6e .github/workflows/_deploy-api.yml Record accepted API release ops/deploy/api-record-accepted-release.sh 9a01a7270546a2afb653d519113972c33117a60b91a903b06b4c909b7f105add -.github/workflows/_deploy-api.yml Roll back failed deployment ops/deploy/api-rollback-failed-deployment.sh cc4ccbc1000b1e1bc96b30261611f88bfee4f4ed64026b698c62877b3d6b7418 +.github/workflows/_deploy-api.yml Roll back failed deployment ops/deploy/api-rollback-failed-deployment.sh b6bee6559cc7f969d197ed956ad6afc6b8d2a0c920fd4adccf5346434229c1a1 .github/workflows/_deploy-api.yml Push CI/CD metrics ops/deploy/api-push-cicd-metrics.sh ef8a2505ec2a58a7d952cf9466bbf2314457d5ba648978b6f7b9daad475e35c6 .github/workflows/_deploy-api.yml 通知飞书(部署结果) ops/deploy/api-notify-feishu.sh fe63c48d3e8699ab378a5ebf101ee6f7e96b27d48a03d0d7a8b0f5a78b94406e .github/workflows/_deploy-api.yml Cleanup Docker credential directory ops/deploy/api-cleanup-docker-credential-directory.sh c8eeae01ac0f6bb6525e18487a06b15cf31611ed9192be57df330fbbad9d0c31