feat: 完善模型平台相关功能

This commit is contained in:
Winnie
2026-07-31 19:10:37 +08:00
parent 86988bdaef
commit 49ee2c0a4a
24 changed files with 529 additions and 125 deletions
+4 -1
View File
@@ -1,6 +1,9 @@
FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PATH="/app/.venv/bin:${PATH}"
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
+72 -1
View File
@@ -12,7 +12,11 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from backend.dependencies import database_session
from backend.dependencies import (
RequestContext,
database_session,
request_context,
)
from backend.runtime_client import RuntimeClientError
from common.config import settings
from common.db.models import Scripts, Users, WorkspaceMembers, Workspaces
@@ -32,6 +36,35 @@ def _b64decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + padding)
def _b64encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
def create_jwt_token(user_id: str, *, expires_seconds: int = 3600) -> str:
header = _b64encode(
json.dumps(
{"alg": JWT_ALGORITHM, "typ": "JWT"},
separators=(",", ":"),
).encode()
)
expires_at = int(time.time()) + expires_seconds
payload = _b64encode(
json.dumps(
{"sub": user_id, "exp": expires_at},
separators=(",", ":"),
).encode()
)
signing_input = f"{header}.{payload}".encode()
signature = _b64encode(
hmac.new(
JWT_SECRET.encode(),
signing_input,
hashlib.sha256,
).digest()
)
return f"{header}.{payload}.{signature}"
def verify_jwt_token(token: str) -> dict:
"""Parse a signed JWT from the access_token cookie.
@@ -168,6 +201,44 @@ async def load_active_membership(
return row
@router.post("/api/v1/auth/demo-session")
async def create_demo_session(
request: Request,
response: Response,
context: RequestContext = Depends(request_context),
) -> dict:
"""Issue the short-lived HttpOnly cookie used by the self-hosted UI.
The normal request-context check still requires an active user/workspace
membership. This endpoint is disabled by default and must be explicitly
enabled by the deployment configuration.
"""
if not settings.demo_auth_enabled:
raise HTTPException(status_code=404, detail="Not Found")
expires_seconds = 3600
response.set_cookie(
"access_token",
create_jwt_token(
context.user.user_id,
expires_seconds=expires_seconds,
),
max_age=expires_seconds,
httponly=True,
secure=request.url.scheme == "https",
samesite="lax",
path="/",
)
return {
"request_id": context.request_id,
"data": {
"user_id": context.user.user_id,
"workspace_id": context.workspace.workspace_id,
"expires_at": int(time.time()) + expires_seconds,
},
"meta": {},
}
@router.get("/api/v1/auth/jupyter")
async def verify_jupyter_access(
request: Request,
+1
View File
@@ -95,6 +95,7 @@ async def complete_resource_upload(
upload_id,
{
"usage_type": "data_resource",
"file_name": payload.resource_name,
"visibility": payload.visibility,
"is_immutable": False,
},
+11
View File
@@ -130,12 +130,20 @@ def script_payload(
) -> dict[str, Any]:
if isinstance(storage_object, dict):
relative_path = storage_object.get("relative_path")
object_key = storage_object.get("object_key")
content_hash = storage_object.get("content_hash")
size_bytes = storage_object.get("size_bytes", 0)
else:
relative_path = storage_object.relative_path
object_key = storage_object.object_key
content_hash = storage_object.content_hash
size_bytes = storage_object.size_bytes
workspace_prefix = f"{script.workspace_id}/"
jupyter_path = (
object_key[len(workspace_prefix):]
if object_key and object_key.startswith(workspace_prefix)
else object_key
)
return {
"script_id": script.script_id,
"workspace_id": script.workspace_id,
@@ -146,6 +154,7 @@ def script_payload(
"visibility": script.visibility,
"status": script.status,
"relative_path": relative_path,
"jupyter_path": jupyter_path,
"content_hash": content_hash,
"size_bytes": size_bytes,
"created_at": script.created_at.isoformat(),
@@ -299,6 +308,7 @@ async def create_script_record(
visibility=visibility,
is_immutable=False,
idempotency_key=f"script:{context.workspace.workspace_id}:{relative_path}",
relative_path=relative_path,
)
object_id = storage_data["storage_object_id"]
script = await session.scalar(
@@ -653,6 +663,7 @@ async def update_script(
f"script-update:{script.script_id}:"
f"{hashlib.sha256(content).hexdigest()}"
),
relative_path=storage_object.relative_path,
)
script.current_object_id = storage_data["storage_object_id"]
script.updated_at = datetime.now(UTC).replace(tzinfo=None)
+26 -7
View File
@@ -99,6 +99,7 @@ def storage_payload(item: StorageObjects) -> dict[str, Any]:
"storage_backend": item.storage_backend,
"usage_type": item.usage_type,
"storage_uri": item.storage_uri,
"object_key": item.object_key,
"relative_path": item.relative_path,
"file_name": item.file_name,
"file_extension": item.file_extension,
@@ -199,12 +200,13 @@ async def create_upload_record(
else:
upload_id = new_ulid()
bucket_name = resolve_bucket(payload.usage_type, workspace=workspace)
# Object key is a flat two-level path: workspace id + upload id. The
# original file name and content type live in the StorageObjects row
# (file_name / mime_type / object_key) — they are not part of the
# key itself, so the bucket can be reorganised without rewriting
# the database.
object_key = f"{payload.workspace_id}/{upload_id}"
# Keep the opaque upload id while preserving the original extension.
# Jupyter selects its editor from this suffix, so an extensionless
# object would make notebooks look like generic JSON/text files.
file_extension = PurePosixPath(
safe_file_name(payload.file_name)
).suffix.lower()
object_key = f"{payload.workspace_id}/{upload_id}{file_extension}"
upload = UploadSessions(
upload_id=upload_id,
workspace_id=payload.workspace_id,
@@ -465,7 +467,21 @@ async def create_server_object(
session,
request)
if upload_result.get("status") == "completed":
return {"data": upload_result["storage_object"], "meta": {"reused": True}}
existing_data = upload_result["storage_object"]
if (
payload.relative_path
and existing_data
and existing_data.get("relative_path") != payload.relative_path
):
existing_item = await session.get(
StorageObjects,
existing_data["storage_object_id"],
)
if existing_item is not None:
existing_item.relative_path = payload.relative_path
await session.flush()
existing_data = storage_payload(existing_item)
return {"data": existing_data, "meta": {"reused": True}}
upload = await session.get(UploadSessions, upload_result["upload_id"])
if upload is None:
@@ -483,10 +499,13 @@ async def create_server_object(
upload.upload_id,
CompleteUploadRequest(
usage_type=payload.usage_type,
file_name=payload.file_name,
visibility=payload.visibility,
is_immutable=payload.is_immutable),
session,
request)
item.relative_path = payload.relative_path
await session.flush()
return {"data": storage_payload(item), "meta": {"reused": False}}