Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/_deploy-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions backend/alembic/versions/b7d2e4f6a813_add_user_is_guest.py
Original file line number Diff line number Diff line change
@@ -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")
55 changes: 53 additions & 2 deletions backend/app/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
import logging
import uuid

from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response

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.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)):
Expand Down Expand Up @@ -38,15 +51,53 @@ 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, user_repo: UserRepository = Depends(get_user_repo)):
"""未登录用户领取游客身份。每次调用都新建一个游客,前端拿到令牌后长期保存复用。"""
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 = 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(
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)"""
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions backend/app/api/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
):
"""上传文件到指定对话"""
Expand All @@ -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),
):
"""初始化浏览器直传上传。"""
Expand All @@ -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),
):
"""确认浏览器直传完成,并接入文件处理管线。"""
Expand Down Expand Up @@ -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),
):
"""删除文件"""
Expand Down
18 changes: 9 additions & 9 deletions backend/app/api/knowledge_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
111 changes: 111 additions & 0 deletions backend/app/core/guest_identity.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading