Compare commits

...

32 Commits

Author SHA1 Message Date
Zayden-Jung 61e3d71b72 make a media api, encapsulate the original S3 bucket address
there are 3 parts, from back to head, url inside the bucket -> url of backend media api -> backend domain, only the last part is spliced by frontend, the other two parts are spliced with sqlalchemy
2026-05-20 17:31:49 +08:00
Zayden-Jung d2162f93e0 fix context; decoupling decode_token algo 2026-05-20 15:40:59 +08:00
Zayden-Jung b95cce592f fullfill graphql usertype; change db session to dep injection 2026-05-20 14:40:05 +08:00
Zayden-Jung f3b7fedade feat(graphql): implement secure session refresh mutation with token rotation 2026-05-20 03:10:07 +08:00
Zayden-Jung 9dfe000f2d refactor(auth): remove legacy RESTful user routes and migrate authentication to GraphQL context
- Rename get_current_user to get_current_user_id in auth/deps.py to clarify intent
- Remove legacy /me, /logout routes, and placeholder function from auth/__init__.py
- Remove dependency_overrides from app/main.py
- Update CustomContext to resolve and cache full User objects via db query
2026-05-20 02:52:54 +08:00
Zayden-Jung d54aeaa61a load dotenv everywhere it's needed; postgresql pool pre ping 2026-05-20 02:21:22 +08:00
Zayden-Jung c68d88b8c1 feat(auth): integrate Google OAuth2 and secure cookie management
- Configured CORS middleware to allow cross-origin requests from the frontend.
- Implemented Google OAuth2 login flow and post-auth frontend redirection.
- Managed secure HTTP-only cookies for access, refresh, and state tokens.
2026-05-19 23:55:03 +08:00
Zayden-Jung 2b5e193046 auth.py is a duplication of google.py; rename jwt.py -> tokens.py avoid confuse with pyjwt 2026-05-19 23:23:21 +08:00
Zayden-Jung a073fa5962 pyjwt not jwt 2026-05-19 22:56:04 +08:00
Zayden-Jung af5f7b38f8 auth/ is now an independent package 2026-05-19 22:31:30 +08:00
Zayden-Jung 623fac8749 app/auth/ is now an independent package, import nothing from app/ 2026-05-19 22:29:21 +08:00
Zayden-Jung fe1fde778f fix jwt token decoding; correctly import google auth routes; adjust app/auth/ package 2026-05-19 22:21:27 +08:00
Zayden-Jung 419af38684 fix missing type annotations for graphql query field arguments 2026-05-19 02:03:35 +08:00
Zayden-Jung b58f74d59b discard adjustable models' id prefix & payload length 2026-05-19 01:54:06 +08:00
Zayden-Jung 86b030930c fix bugs for launch: postgresql url for sqlalchemy; auto loading env 2026-05-19 01:39:57 +08:00
Zayden-Jung a83befe109 use strawberry dataLoader to solve N+1 query problem 2026-05-19 00:28:04 +08:00
Zayden-Jung 9c55cc675e introduce graphql 2026-05-19 00:24:45 +08:00
Zayden-Jung 78f15c80a1 enrich user-project interaction statistics 2026-05-19 00:05:56 +08:00
Zayden-Jung 0377d5071f background task for s3 avatar uploading 2026-05-18 23:51:59 +08:00
Zayden-Jung 4388a6781a add default avatar gen is google avatar not exists 2026-05-18 23:45:17 +08:00
Zayden-Jung 6ed6d38273 secure avatar to s3 by exception control 2026-05-18 23:39:56 +08:00
Zayden-Jung d73118ad79 auto id length in bi ref: workspace 2026-05-18 23:30:29 +08:00
Zayden-Jung 41fc874481 auto id length in bi ref: library 2026-05-18 23:28:44 +08:00
Zayden-Jung 60999c4271 auto id length in bi ref: project 2026-05-18 23:27:42 +08:00
Zayden-Jung e75c243dcc add user statistics 2026-05-18 23:22:27 +08:00
Zayden-Jung 7a401fa904 rename project node -> project item 2026-05-18 22:54:37 +08:00
Zayden-Jung 8ea64de612 auto id length in bi ref: interaction 2026-05-18 22:53:02 +08:00
Zayden-Jung 4a48982412 import alembic, even through too early 2026-05-18 22:49:04 +08:00
Zayden-Jung d457c07f40 table follow requires the id length of user 2026-05-18 22:36:23 +08:00
Zayden-Jung a73b3cce26 retains the original style while supporting custom lengths 2026-05-18 22:30:07 +08:00
Zayden-Jung abcdea2645 custom id prefix and payload length 2026-05-18 22:24:22 +08:00
Zayden-Jung 333dd9dd35 add social interactions, rich media metadata, and workspace description 2026-05-17 22:49:26 +08:00
31 changed files with 1353 additions and 210 deletions
+15 -4
View File
@@ -6,16 +6,27 @@ prompts:
preset: preset:
auth: auth:
- "app/auth.py" - "auth/*"
- "app/auth/*"
s3: s3:
- "app/models/base.py" - "app/models/base.py"
- "app/models/user.py" - "app/models/user.py"
- "app/utils/s3.py" - "app/utils/s3.py"
- "app/auth.py" - "auth.py"
db_model: db_model:
- "app/models/*" - "app/models/*"
- "app/db.py" - "app/db.py"
- "app/auth.py" - "app/main.py"
graphql:
- "app/graphql/*"
- "app/main.py"
get_current_user:
- "app/main.py"
- "auth/__init__.py"
- "auth/deps.py"
- "auth/tokens.py"
- "auth/oauth/google.py"
- "app/graphql/*"
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+78
View File
@@ -0,0 +1,78 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
-145
View File
@@ -1,145 +0,0 @@
import httpx
from fastapi import APIRouter, Depends, Request, HTTPException
from fastapi.responses import RedirectResponse
import os
import secrets
from app.auth.deps import get_current_user
from app.db import SessionLocal
from app.models import User
from app.auth.jwt import create_jwt_token, create_refresh_token, decode_token
from app.utils.format import get_image_url
router = APIRouter()
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI")
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
@router.get("/login/google")
def login_google():
state = secrets.token_urlsafe(16)
url = (
f"{GOOGLE_AUTH_URL}"
f"?client_id={GOOGLE_CLIENT_ID}"
f"&redirect_uri={GOOGLE_REDIRECT_URI}"
f"&response_type=code"
f"&scope=openid email profile"
f"&state={state}"
)
response = RedirectResponse(url)
response.set_cookie(
"oauth_state",
state,
httponly=True,
secure=True,
samesite="lax"
)
return response
@router.get("/auth/callback")
async def auth_callback(request: Request, code: str, state: str):
cookie_state = request.cookies.get("oauth_state")
if not cookie_state or cookie_state != state:
raise HTTPException(status_code=400, detail="Invalid state")
async with httpx.AsyncClient() as client:
token_res = await client.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uri": GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
},
)
token_json = token_res.json()
access_token = token_json["access_token"]
user_res = await client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
user_info = user_res.json()
db = SessionLocal()
user = db.query(User).filter(User.email == user_info["email"]).first()
if not user:
user = User(
google_id=user_info["id"],
email=user_info["email"],
name=user_info["name"],
avatar=user_info["picture"],
)
db.add(user)
db.commit()
db.refresh(user)
# 异步/同步将头像转存到 S3
from app.utils.s3 import upload_google_avatar_to_s3
s3_avatar_url = await upload_google_avatar_to_s3(user_info["picture"], str(user.id))
# 将更新后的 S3 URL 写回数据库
user.avatar = s3_avatar_url
db.commit()
# 这里不需要再次 refresh,除非后面还要用到更新后的 user 对象
# db.refresh(user)
db.close()
jwt_token = create_jwt_token({
"sub": user.id,
"email": user.email,
})
refresh_token = create_refresh_token({
"sub": user.id,
"email": user.email,
})
return {
"access_token": jwt_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"user": {
"email": user.email,
"name": user.name,
"avatar": get_image_url(user.avatar),
}
}
@router.get("/me")
def me(user = Depends(get_current_user)):
return {
"email": user.email,
"name": user.name,
"avatar": get_image_url(user.avatar),
}
@router.post("/refresh")
def refresh_token(refresh_token: str):
payload = decode_token(refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=401)
user_id = payload.get("sub")
new_access = create_jwt_token({"sub": user_id})
return {"access_token": new_access}
@router.post("/logout")
def logout():
return {"message": "ok"}
+32 -1
View File
@@ -1,8 +1,39 @@
import os import os
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from dotenv import load_dotenv
# 加载 .env 文件
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL") DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_engine(DATABASE_URL) # 自动将旧版的 postgres:// 替换为 SQLAlchemy 强要求的 postgresql://
if DATABASE_URL and DATABASE_URL.startswith("postgres://"):
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1)
elif DATABASE_URL and DATABASE_URL.startswith("postgres+"):
DATABASE_URL = DATABASE_URL.replace("postgres+", "postgresql+", 1)
# 如果环境变量彻底缺失,做一个安全的报错提示
if not DATABASE_URL:
raise ValueError("DATABASE_URL environment variable is not set or empty.")
engine = create_engine(
DATABASE_URL,
pool_recycle=3600,
pool_pre_ping=True,
connect_args={
"keepalives": 1,
"keepalives_idle": 30,
"keepalives_interval": 10,
"keepalives_count": 5
}
)
SessionLocal = sessionmaker(bind=engine) SessionLocal = sessionmaker(bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+74
View File
@@ -0,0 +1,74 @@
from app.models.user import User
from auth.deps import get_current_user_id
from fastapi import Request
from strawberry.fastapi import BaseContext
from strawberry.dataloader import DataLoader
from typing import List, Optional
from collections import defaultdict
from app.db import SessionLocal
from app.models import WorkspaceMember
from typing import AsyncGenerator
# 定义批量查询函数:接收一组 workspace ID 列表,返回相同长度的结果列表
async def load_members_by_workspace_ids(workspace_ids: List[str]) -> List[List[WorkspaceMember]]:
db = SessionLocal()
try:
# 用一条 IN 语句查出所有相关成员
members = db.query(WorkspaceMember).filter(WorkspaceMember.workspace_id.in_(workspace_ids)).all()
# 将结果归类到对应的 workspace ID
member_map = defaultdict(list)
for member in members:
member_map[member.workspace_id].append(member)
# 严格按照传入的 workspace ID 顺序返回结果列表
return [member_map[w_id] for w_id in workspace_ids]
finally:
db.close()
class CustomContext(BaseContext):
def __init__(self, request: Request):
super().__init__()
self.db = SessionLocal()
self.request = request
self._current_user: Optional[User] = None
self.members_loader = DataLoader(load_fn=load_members_by_workspace_ids)
async def get_current_user(self) -> User | None:
"""在 Context 级别负责把 user_id 变成真正的数据库 User 对象"""
if self._current_user is not None:
return self._current_user
try:
# 打印 Request Headers,确认 Token 是否真的传过来了
# print(f"DEBUG: Auth Headers: {self.request.headers.get('authorization')}")
# print(f"DEBUG: Cookies: {self.request.cookies}")
# 借用 auth 包的纯工具解出 ID
user_id = get_current_user_id(self.request)
# print(f"DEBUG: Extracted User ID: {user_id}") # 确认 ID 是否解出
if not user_id:
return None
# 在 Context 的数据库连接中查询完整用户
user = self.db.query(User).filter(User.id == user_id).first()
# print(f"DEBUG: DB User Found: {user}") # 确认数据库是否查到
self._current_user = user
return user
except Exception as e:
# 打印真实的异常信息
# print(f"DEBUG: Error in get_current_user: {str(e)}")
import traceback
traceback.print_exc() # 打印堆栈
return None
async def get_context(request: Request) -> AsyncGenerator[CustomContext, None]:
# print("--- NEW GRAPHQL REQUEST RECEIVED ---")
context = CustomContext(request)
try:
yield context
finally:
context.db.close() # 确保请求结束时关闭数据库连接
+103
View File
@@ -0,0 +1,103 @@
import os
import strawberry
from strawberry.types import Info
from dotenv import load_dotenv
from urllib.parse import quote
import json
from app.graphql.context import CustomContext
from auth.tokens import decode_token, create_jwt_token, create_refresh_token
from app.utils.format import get_image_url
load_dotenv()
ENV_MODE = os.getenv("ENV_MODE")
IS_PROD = ENV_MODE == "prod"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
ACCESS_TOKEN_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60
@strawberry.type
class Mutation:
@strawberry.mutation
def logout(self, info: Info[CustomContext, None]) -> str:
"""退出登录:清除浏览器的 HttpOnly Cookies"""
response = info.context.response
# 让浏览器立刻过期这些 cookie
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
response.delete_cookie("user_info")
return "ok"
@strawberry.mutation
async def refresh_session(self, info: Info[CustomContext, None]) -> str:
"""
刷新 Session:如果 access_token 过期了,前端可以调用这个接口用 refresh_token 换新的 access_token。
同时如果 refresh_token 已经过半寿命了,这个接口也会顺便轮转一个新的 refresh_token,确保用户体验的连续性。
"""
request = info.context.request
response = info.context.response
# 从 Cookie 获取并校验 refresh_token
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise Exception("No refresh token found in cookies")
try:
payload = decode_token(refresh_token)
except Exception:
raise Exception("Invalid or expired refresh token")
if payload.get("type") != "refresh":
raise Exception("Token type mismatch")
user = await info.context.get_current_user()
# 如果 access_token 已经彻底过期导致 Context 没捞到用户,
# 我们用 refresh_token 里的 sub 去 Context 的 db 里再查一遍用户
from app.models.user import User
if not user:
user_id = payload.get("sub")
user = info.context.db.query(User).filter(User.id == user_id).first()
if not user:
raise Exception("User no longer exists")
# 始终生成并写入新的短效 access_token
token_data = {"sub": str(user.id), "email": user.email}
new_access = create_jwt_token(token_data)
ACCESS_TOKEN_MAX_AGE = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")) * 60
response.set_cookie(
key="access_token", value=new_access, httponly=True,
max_age=ACCESS_TOKEN_MAX_AGE, samesite="lax", secure=IS_PROD,
)
# 检查 refresh_token 是否已经消耗了超过一半的生命周期
import time
exp = payload.get("exp") # 过期绝对秒数
iat = payload.get("iat") # 签发绝对秒数
now = int(time.time())
# 如果当前时间已经过了总寿命的一半,就触发轮转,下发新的 refresh_token
if (now - iat) > (exp - iat) / 2:
new_refresh = create_refresh_token(token_data)
REFRESH_TOKEN_MAX_AGE = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7")) * 24 * 60 * 60
response.set_cookie(
key="refresh_token", value=new_refresh, httponly=True,
max_age=REFRESH_TOKEN_MAX_AGE, samesite="lax", secure=IS_PROD,
)
# 同步写入并续期前端所需的 user_info
response.set_cookie(
key="user_info",
value=quote(json.dumps({
"email": user.email,
"name": user.name,
"avatar": get_image_url(user.avatar) if user.avatar else None
})),
max_age=ACCESS_TOKEN_MAX_AGE,
httponly=False
)
return "ok"
+21
View File
@@ -0,0 +1,21 @@
import strawberry
from typing import Optional
from strawberry.types import Info
from app.graphql.types import UserType
from app.models import User
from app.graphql.context import CustomContext
@strawberry.type
class Query:
@strawberry.field
async def me(self, info: Info[CustomContext, None]) -> Optional[UserType]:
# 从 context 获取当前登录用户
user = await info.context.get_current_user()
# Strawberry 会自动将 SQLAlchemy 实例映射为 UserType
return user
@strawberry.field
def get_user_by_handle(self, info: Info[CustomContext, None], handle: str) -> Optional[UserType]:
db = info.context.db
return db.query(User).filter(User.handle == handle).first()
+67
View File
@@ -0,0 +1,67 @@
import strawberry
from typing import List, Optional
from datetime import datetime
from strawberry.types import Info
from app.utils.format import get_image_url
from app.models.user import User
@strawberry.type
class UserType:
id: str
email: str
name: str
handle: Optional[str]
@strawberry.field
def avatar(self, root: User) -> Optional[str]:
return root.avatar_url
# 互动统计字段
following_count: int
follower_count: int
# 统计汇总字段
owner_project_count: int
owner_likes_count: int
owner_favorites_count: int
owner_shares_count: int
admin_project_count: int
admin_likes_count: int
admin_favorites_count: int
admin_shares_count: int
user_project_count: int
user_likes_count: int
user_favorites_count: int
user_shares_count: int
created_at: datetime
# 动态解析字段:例如自动处理头像的绝对路径
@strawberry.field
def avatar_url(self) -> Optional[str]:
return get_image_url(self.avatar) if self.avatar else None
@strawberry.type
class WorkspaceMemberType:
id: strawberry.ID
workspace_id: str
user_id: Optional[str]
role: str
status: str
@strawberry.type
class WorkspaceType:
id: strawberry.ID
name: str
slug: str
logo: Optional[str]
description: Optional[str]
storage_used: int
# 支持前端按需加载关联的成员
@strawberry.field
def members(self, info: Info) -> List[WorkspaceMemberType]:
return info.context.members_loader.load(self.id)
+49 -5
View File
@@ -1,10 +1,54 @@
from fastapi import FastAPI import os
from app.auth import router as auth_router from fastapi import FastAPI, Depends, HTTPException
from app.models import Base from fastapi.middleware.cors import CORSMiddleware
from app.db import engine from strawberry import Schema
from strawberry.fastapi import GraphQLRouter
from dotenv import load_dotenv
from auth import router as auth_router
from app.media.router import router as media_router
from app.db import engine, SessionLocal
from app.models import Base, User
from app.utils.format import get_image_url
# 导入 GraphQL 组件
from app.graphql.queries import Query
from app.graphql.mutations import Mutation
from app.graphql.context import get_context
# 自动建表
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# 创建 FastAPI 实例
app = FastAPI() app = FastAPI()
app.include_router(auth_router) # 获取环境变量字符串,并转成列表
load_dotenv()
origins_str = os.getenv("CORS_ORIGINS", "http://localhost:3000")
origins = [origin.strip() for origin in origins_str.split(",")]
# 配置 CORS 中间件,允许前端访问
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 组装 GraphQL Schema
schema = Schema(query=Query, mutation=Mutation)
# 创建 GraphQL 路由并注入上下文
graphql_router = GraphQLRouter(schema, context_getter=get_context)
# 挂载路由
app.include_router(auth_router) # 保留现有的 auth 路由供 Google OAuth 回调使用
app.include_router(media_router) # 挂载媒体路由,提供头像代理访问等等
app.include_router(graphql_router, prefix="/graphql") # 挂载 GraphQL 终点,前端以后只需要请求 /graphql
# 打印路由测试
for route in app.routes:
methods = getattr(route, "methods", "N/A")
print(f"Path: {route.path} | Name: {route.name} | Methods: {methods}")
+45
View File
@@ -0,0 +1,45 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
import oss2
from app.utils.s3 import bucket
router = APIRouter(prefix="/v1/media", tags=["Media"])
@router.get("/{file_path:path}")
async def get_avatar(file_path: str):
"""
通过流式传输代理阿里云 OSS 中的图片
file_path 接收 avatars/xxx/avatar.jpg 等任何相对路径
"""
s3_key = file_path.lstrip("/")
if not s3_key:
raise HTTPException(status_code=400, detail="Invalid file path")
try:
# 从阿里云 OSS 获取对象
# oss2 的 get_object 是同步阻塞的
# 初期可以直接用,后续高并发可以放入 run_in_executor
oss_object = bucket.get_object(s3_key)
# 获取文件的 Content-Type
content_type = oss_object.headers.get("Content-Type", "image/jpeg")
# 创建一个生成器来分块读取 OSS 数据,防止大文件时撑爆内存
def image_stream():
while True:
chunk = oss_object.read(1024 * 64) # 每次读 64KB
if not chunk:
break
yield chunk
# 流式返回给前端
return StreamingResponse(image_stream(), media_type=content_type)
except oss2.exceptions.NoSuchKey:
# 阿里云 OSS 专属的 404 异常
raise HTTPException(status_code=404, detail="Image not found in storage")
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail="Internal storage service error")
+7 -3
View File
@@ -3,8 +3,9 @@ from .user import User
from .message import Message from .message import Message
from .follow import Follow from .follow import Follow
from .workspace import Workspace, WorkspaceMember from .workspace import Workspace, WorkspaceMember
from .project import ProjectNode from .project import ProjectItem
from .library import LibraryItem from .library import LibraryItem
from .interaction import ProjectLike, ProjectFavorite, ProjectShare
__all__ = [ __all__ = [
"Base", "Base",
@@ -13,6 +14,9 @@ __all__ = [
"Follow", "Follow",
"Workspace", "Workspace",
"WorkspaceMember", "WorkspaceMember",
"ProjectNode", "ProjectItem",
"LibraryItem" "LibraryItem",
"ProjectLike",
"ProjectFavorite",
"ProjectShare"
] ]
+40 -21
View File
@@ -9,19 +9,25 @@ Base = declarative_base()
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
def encode_base62(num: int) -> str: def encode_base62(num: int, length: int = None) -> str:
"""将一个整数编码为 Base62 字符串""" """将一个整数编码为 Base62 字符串,支持左侧补 0 固定长度"""
if num == 0: if num == 0:
return ALPHABET[0] res = ALPHABET[0]
arr = [] else:
base = len(ALPHABET) arr = []
while num: base = len(ALPHABET)
num, rem = divmod(num, base) while num:
arr.append(ALPHABET[rem]) num, rem = divmod(num, base)
return "".join(reversed(arr)) arr.append(ALPHABET[rem])
res = "".join(reversed(arr))
if length:
# 如果太长就截取,太短就用第一个字符(0)补齐
return res.rjust(length, ALPHABET[0])[-length:]
return res
def encode_tablename_to_prefix(tablename: str, length: int = 3) -> str: def encode_tablename_to_prefix(tablename: str, length: int) -> str:
""" """
将表名 encode 成固定长度的前缀,使用 MD5 拿到表名的唯一哈希值,转为整数后用 Base62 截取前 N 位 将表名 encode 成固定长度的前缀,使用 MD5 拿到表名的唯一哈希值,转为整数后用 Base62 截取前 N 位
""" """
@@ -33,33 +39,46 @@ def encode_tablename_to_prefix(tablename: str, length: int = 3) -> str:
num = int(hex_digest, 16) num = int(hex_digest, 16)
# 转为 Base62 字符串 # 转为 Base62 字符串
b62_str = encode_base62(num) return encode_base62(num, length=length)
# 截取固定长度(例如 3 位)作为该表的专属代码
return b62_str[:length]
def gen_convex_payload() -> str: def gen_convex_payload(length: int) -> str:
"""生成 128位 ULID 转 Base62 的趋势递增 Payload""" """生成固定长度的 ULID Payload"""
u = ulid.new() u = ulid.new()
return encode_base62(u.int) return encode_base62(u.int, length=length)
# --- 注入器类 ---
class AutoID: class AutoID:
""" """
全自动 Convex 风格 ID 注入器 全自动 Convex 风格 ID 注入器
不再需要手动定义任何 prefix,全自动根据 __tablename__ 编码生成 不再需要手动定义任何 prefix,全自动根据 __tablename__ 编码生成
支持子类自定义长度
""" """
# 全局固定配置
ID_PREFIX_LEN = 3
ID_PAYLOAD_LEN = 22
@classmethod
def id_length(cls) -> int:
"""返回当前类 ID 的总长度"""
return cls.ID_PREFIX_LEN + cls.ID_PAYLOAD_LEN
@declared_attr @declared_attr
def id(cls): def id(cls):
# 运行时动态获取子类的 __tablename__ # 运行时动态获取子类的 __tablename__
tablename = cls.__tablename__ tablename = cls.__tablename__
# 将表名编码为固定前缀(例如 "users" -> "7X1" # 获取子类配置的长度
table_prefix = encode_tablename_to_prefix(tablename, length=3) p_len = AutoID.ID_PREFIX_LEN
s_len = AutoID.ID_PAYLOAD_LEN
prefix = encode_tablename_to_prefix(tablename, length=p_len)
# 核心工厂函数:拼接【编码后的表前缀】和【递增Payload】 # 核心工厂函数:拼接【编码后的表前缀】和【递增Payload】
def id_factory(): def id_factory():
return f"{table_prefix}_{gen_convex_payload()}" payload = gen_convex_payload(length=s_len)
return f"{prefix}{payload}"
return Column(String(32), primary_key=True, default=id_factory) return Column(String(cls.id_length()), primary_key=True, default=id_factory)
+10 -2
View File
@@ -6,8 +6,16 @@ class Follow(Base, AutoID):
"""用户关注关系表""" """用户关注关系表"""
__tablename__ = "follows" __tablename__ = "follows"
follower_id = Column(String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True) follower_id = Column(
followee_id = Column(String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True) String(AutoID.id_length()),
ForeignKey("users.id", ondelete="CASCADE"),
index=True
)
followee_id = Column(
String(AutoID.id_length()),
ForeignKey("users.id", ondelete="CASCADE"),
index=True
)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
+72
View File
@@ -0,0 +1,72 @@
from sqlalchemy import Column, DateTime, String, ForeignKey, UniqueConstraint
from sqlalchemy.sql import func
from .base import AutoID, Base
class ProjectLike(Base, AutoID):
"""项目点赞表"""
__tablename__ = "project_likes"
user_id = Column(
String(AutoID.id_length()),
ForeignKey("users.id", ondelete="CASCADE"),
index=True
)
project_item_id = Column(
String(AutoID.id_length()),
ForeignKey("project_items.id", ondelete="CASCADE"),
index=True
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# 联合唯一约束,防止同一用户重复点赞
__table_args__ = (UniqueConstraint("user_id", "project_item_id", name="uq_user_project_like"),)
class ProjectFavorite(Base, AutoID):
"""项目收藏表"""
__tablename__ = "project_favorites"
user_id = Column(
String(AutoID.id_length()),
ForeignKey("users.id", ondelete="CASCADE"),
index=True
)
project_item_id = Column(
String(AutoID.id_length()),
ForeignKey("project_items.id", ondelete="CASCADE"),
index=True
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# 联合唯一约束,防止重复收藏
__table_args__ = (UniqueConstraint("user_id", "project_item_id", name="uq_user_project_favorite"),)
class ProjectShare(Base, AutoID):
"""项目转发/分享记录表"""
__tablename__ = "project_shares"
user_id = Column(
String(AutoID.id_length()),
ForeignKey("users.id", ondelete="CASCADE"),
index=True
)
project_item_id = Column(
String(AutoID.id_length()),
ForeignKey("project_items.id", ondelete="CASCADE"),
index=True
)
# 转发去向或渠道,例如 'internal' (转发到其他工作区), 'twitter', 'link' (复制链接)
share_target = Column(String, default="internal", nullable=False)
# 如果是转发到特定工作区,可以记录目标工作区 ID
# target_workspace_id = Column(
# String(Workspace.id_length()),
# ForeignKey("workspaces.id", ondelete="SET NULL"),
# nullable=True
# )
created_at = Column(DateTime(timezone=True), server_default=func.now())
+17 -4
View File
@@ -6,18 +6,31 @@ class LibraryItem(Base, AutoID):
"""工作区共享媒体/文件资源库""" """工作区共享媒体/文件资源库"""
__tablename__ = "library_items" __tablename__ = "library_items"
workspace_id = Column(String(32), ForeignKey("workspaces.id", ondelete="CASCADE"), index=True) workspace_id = Column(
String(AutoID.id_length()),
ForeignKey("workspaces.id", ondelete="CASCADE"),
index=True,
)
# 自引用外键,支持无限级文件夹嵌套 # 自引用外键,支持无限级文件夹嵌套
parent_id = Column(String(32), ForeignKey("library_items.id", ondelete="CASCADE"), nullable=True, index=True) parent_id = Column(
String(AutoID.id_length()),
ForeignKey("library_items.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
name = Column(String, nullable=False) name = Column(String, nullable=False)
# 节点类型:'folder' (文件夹) 或 'file' (真实文件) # 节点类型:'folder' (文件夹) 或 'file' (真实文件)
item_type = Column(String, default="file", nullable=False) item_type = Column(String, default="file", nullable=False)
# --- 以下为真实文件(item_type='file')的核心字段 --- # --- 以下为真实文件(item_type='file')的核心字段 ---
s3_key = Column(String, nullable=True) # S3 中的 Object Key s3_key = Column(String, nullable=True) # S3 中的 Object Key
file_size = Column(BigInteger, nullable=True) # 文件大小(字节数 Bytes),用于统计容量占用 file_size = Column(BigInteger, nullable=True) # 文件大小(字节数 Bytes),用于统计容量占用
mime_type = Column(String, nullable=True) # 媒体类型,例如 'image/jpeg', 'video/mp4', 'application/pdf' mime_type = Column(String, nullable=True) # 媒体类型,例如 'image/jpeg', 'video/mp4', 'application/pdf'
uploaded_by = Column(String(32), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) uploaded_by = Column(
String(AutoID.id_length()),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
+26 -6
View File
@@ -1,14 +1,24 @@
from sqlalchemy import Column, DateTime, String, ForeignKey from sqlalchemy import Column, DateTime, String, ForeignKey, Integer
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .base import AutoID, Base from .base import AutoID, Base
class ProjectNode(Base, AutoID): class ProjectItem(Base, AutoID):
"""项目与文件夹的树状虚拟文件系统""" """项目与文件夹的树状虚拟文件系统"""
__tablename__ = "project_nodes" __tablename__ = "project_items"
workspace_id = Column(String(32), ForeignKey("workspaces.id", ondelete="CASCADE"), index=True) workspace_id = Column(
# 自引用外键:如果是根目录下的顶级节点,则 parent_id 为 Null String(AutoID.id_length()),
parent_id = Column(String(32), ForeignKey("project_nodes.id", ondelete="CASCADE"), nullable=True, index=True) ForeignKey("workspaces.id", ondelete="CASCADE"),
index=True,
)
# 自引用外键
# 如果是根目录下的顶级节点,则 parent_id 为 Null
parent_id = Column(
String(AutoID.id_length()), # 其实调用的就是自身的 id_length 方法
ForeignKey("project_items.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
name = Column(String, nullable=False) name = Column(String, nullable=False)
# 节点类型:'folder' (文件夹) 或 'project' (代表具体文件的项目实体) # 节点类型:'folder' (文件夹) 或 'project' (代表具体文件的项目实体)
@@ -17,4 +27,14 @@ class ProjectNode(Base, AutoID):
# 如果是 project 节点,可以用这一列来存储其内容或配置快照(如 JSON 文本) # 如果是 project 节点,可以用这一列来存储其内容或配置快照(如 JSON 文本)
content = Column(String, nullable=True) content = Column(String, nullable=True)
# --- 富媒体与播放信息 ---
cover_url = Column(String, nullable=True) # 封面图(可以是图片、GIF 或短视频预览 URL)
duration = Column(Integer, default=0) # 媒体时长(单位:秒,非视频/GIF 则为 0)
view_count = Column(Integer, default=0, nullable=False)
like_count = Column(Integer, default=0, nullable=False, server_default="0")
favorite_count = Column(Integer, default=0, nullable=False, server_default="0")
share_count = Column(Integer, default=0, nullable=False, server_default="0")
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+40 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, DateTime, String from sqlalchemy import Column, DateTime, String, Integer
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .base import AutoID, Base from .base import AutoID, Base
@@ -6,6 +6,10 @@ from .base import AutoID, Base
class User(Base, AutoID): class User(Base, AutoID):
__tablename__ = "users" __tablename__ = "users"
# 自定义配置
# _id_prefix_len = 4
# _id_payload_len = 16
google_id = Column(String, unique=True, index=True) google_id = Column(String, unique=True, index=True)
email = Column(String, unique=True, index=True) email = Column(String, unique=True, index=True)
name = Column(String) name = Column(String)
@@ -13,6 +17,41 @@ class User(Base, AutoID):
handle = Column(String, unique=True, index=True, nullable=True) handle = Column(String, unique=True, index=True, nullable=True)
avatar = Column(String) avatar = Column(String)
@property
def avatar_url(self) -> str | None:
if not self.avatar:
return None
# 如果是第三方(如未迁移成功时降级的 Google 原始链接),直接返回
if self.avatar.startswith(("http://", "https://")):
return self.avatar
path = self.avatar.lstrip("/")
return f"/v1/media/{path}"
# ---- 互动统计字段 ----
following_count = Column(Integer, default=0, nullable=False, server_default="0") # 关注了多少人
follower_count = Column(Integer, default=0, nullable=False, server_default="0") # 粉丝数
# 作为 Owner (所有者) 身份在各个空间获得的累计总数
owner_project_count = Column(Integer, default=0, nullable=False, server_default="0")
owner_likes_count = Column(Integer, default=0, nullable=False, server_default="0")
owner_favorites_count = Column(Integer, default=0, nullable=False, server_default="0")
owner_shares_count = Column(Integer, default=0, nullable=False, server_default="0")
# 作为 Admin (管理员) 身份在各个空间获得的累计总数
admin_project_count = Column(Integer, default=0, nullable=False, server_default="0")
admin_likes_count = Column(Integer, default=0, nullable=False, server_default="0")
admin_favorites_count = Column(Integer, default=0, nullable=False, server_default="0")
admin_shares_count = Column(Integer, default=0, nullable=False, server_default="0")
# 作为 User (普通用户) 身份在各个空间获得的累计总数
user_project_count = Column(Integer, default=0, nullable=False, server_default="0")
user_likes_count = Column(Integer, default=0, nullable=False, server_default="0")
user_favorites_count = Column(Integer, default=0, nullable=False, server_default="0")
user_shares_count = Column(Integer, default=0, nullable=False, server_default="0")
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
def __repr__(self): def __repr__(self):
+22 -5
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, DateTime, String, ForeignKey, BigInteger, UniqueConstraint from sqlalchemy import Column, DateTime, String, ForeignKey, BigInteger, UniqueConstraint, Integer
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .base import AutoID, Base from .base import AutoID, Base
@@ -12,6 +12,11 @@ class Workspace(Base, AutoID):
logo = Column(String, nullable=True) logo = Column(String, nullable=True)
description = Column(String, nullable=True) description = Column(String, nullable=True)
total_project_count = Column(Integer, default=0, nullable=False, server_default="0")
total_like_count = Column(Integer, default=0, nullable=False, server_default="0")
total_favorite_count = Column(Integer, default=0, nullable=False, server_default="0")
total_share_count = Column(Integer, default=0, nullable=False, server_default="0")
# 存储容量上限(单位:字节 Bytes),默认 5GB # 存储容量上限(单位:字节 Bytes),默认 5GB
storage_limit = Column(BigInteger, default=5 * 1024 * 1024 * 1024) storage_limit = Column(BigInteger, default=5 * 1024 * 1024 * 1024)
storage_used = Column(BigInteger, default=0, nullable=False) storage_used = Column(BigInteger, default=0, nullable=False)
@@ -24,11 +29,18 @@ class WorkspaceMember(Base, AutoID):
"""工作区成员及权限表""" """工作区成员及权限表"""
__tablename__ = "workspace_members" __tablename__ = "workspace_members"
workspace_id = Column(String(32), ForeignKey("workspaces.id", ondelete="CASCADE"), index=True) workspace_id = Column(
user_id = Column(String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True) String(AutoID.id_length()),
ForeignKey("workspaces.id", ondelete="CASCADE"),
index=True,
)
# 允许 user_id 为空,因为被邀请的人可能还没注册系统,我们先记录他的邮箱 # 允许 user_id 为空,因为被邀请的人可能还没注册系统,我们先记录他的邮箱
user_id = Column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True) user_id = Column(
String(AutoID.id_length()),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
invite_email = Column(String, nullable=True) # 收到邀请的邮箱 invite_email = Column(String, nullable=True) # 收到邀请的邮箱
# 状态:'pending' (等待加入), 'active' (已加入), 'disabled' (已禁用) # 状态:'pending' (等待加入), 'active' (已加入), 'disabled' (已禁用)
@@ -36,6 +48,11 @@ class WorkspaceMember(Base, AutoID):
# 角色权限:'owner' (所有者), 'admin' (管理员), 'user' (普通用户) # 角色权限:'owner' (所有者), 'admin' (管理员), 'user' (普通用户)
role = Column(String, default="user", nullable=False) role = Column(String, default="user", nullable=False)
likes_received_count = Column(Integer, default=0, nullable=False, server_default="0")
favorites_received_count = Column(Integer, default=0, nullable=False, server_default="0")
shares_received_count = Column(Integer, default=0, nullable=False, server_default="0")
# invite_token 用于生成邀请链接,token_expires_at 用于设置链接过期时间
invite_token = Column(String, nullable=True, unique=True) invite_token = Column(String, nullable=True, unique=True)
token_expires_at = Column(DateTime(timezone=True), nullable=True) token_expires_at = Column(DateTime(timezone=True), nullable=True)
+3
View File
@@ -1,4 +1,7 @@
import os import os
from dotenv import load_dotenv
load_dotenv()
OSS_BASE_URL = os.getenv("OSS_BASE_URL", "").rstrip("/") OSS_BASE_URL = os.getenv("OSS_BASE_URL", "").rstrip("/")
+3 -2
View File
@@ -1,8 +1,9 @@
import oss2 import oss2
import httpx import httpx
import os import os
import io from dotenv import load_dotenv
from fastapi import HTTPException
load_dotenv()
# 加载配置 # 加载配置
ACCESS_KEY_ID = os.getenv("S3_ACCESS_KEY_ID") ACCESS_KEY_ID = os.getenv("S3_ACCESS_KEY_ID")
+19
View File
@@ -0,0 +1,19 @@
import os
from fastapi import APIRouter
from .deps import get_current_user_id
from .tokens import create_jwt_token, create_refresh_token, verify_access_token
from .oauth import oauth_router
# 创建全局 auth 路由器并挂载第三方 OAuth 的路由器
router = APIRouter(tags=["Authentication"])
router.include_router(oauth_router)
# 显式声明暴露的接口
__all__ = [
"router",
"get_current_user_id",
"create_jwt_token",
"create_refresh_token",
"verify_access_token",
]
+9 -5
View File
@@ -1,19 +1,23 @@
from fastapi import Request, HTTPException from fastapi import Request, HTTPException
import app.auth.jwt as jwt
import os import os
from dotenv import load_dotenv
from .tokens import decode_token
load_dotenv()
JWT_SECRET = os.getenv("JWT_SECRET") JWT_SECRET = os.getenv("JWT_SECRET")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
def get_current_user_id(request: Request):
def get_current_user(request: Request): """仅从 Cookie 中提取并解密出用户的 user_id (sub)"""
token = request.cookies.get("access_token") token = request.cookies.get("access_token")
if not token: if not token:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
try: try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) payload = decode_token(token)
return payload["user_id"] return payload["sub"]
except: except:
raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail="Invalid token")
+12
View File
@@ -0,0 +1,12 @@
from fastapi import APIRouter
# 导入各个渠道的路由
from .google import router as google_router
# from .github import router as github_router
# 创建一个总的 oauth 路由器
oauth_router = APIRouter()
# 包含 Google 的路由
oauth_router.include_router(google_router)
# oauth_router.include_router(github_router)
+217
View File
@@ -0,0 +1,217 @@
from ast import Is
import httpx
from fastapi import APIRouter, Depends, Request, HTTPException, BackgroundTasks
from fastapi.responses import RedirectResponse
import os
import secrets
import json
from urllib.parse import quote
from dotenv import load_dotenv
from sqlalchemy.orm import Session
from app.db import SessionLocal, get_db
from app.models import User
from auth.tokens import create_jwt_token, create_refresh_token, decode_token
from app.utils.format import get_image_url
router = APIRouter()
load_dotenv()
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI")
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
ENV_MODE = os.getenv("ENV_MODE")
IS_PROD = ENV_MODE == "prod"
# 在文件顶部获取环境变量
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
# 统一转换为秒数(max_age 需要秒)
ACCESS_TOKEN_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60
REFRESH_TOKEN_MAX_AGE = REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
# 定义独立的后台任务函数,用于处理头像转存到 S3 的耗时操作
async def async_upload_avatar_task(google_avatar_url: str, user_id: str):
"""
后台任务函数:下载 Google 头像并上传到 S3,然后更新用户记录。
因为主接口的 db 已经关闭,这里需要独立管理 db 的生命周期。
"""
from app.utils.s3 import upload_google_avatar_to_s3
# 如果 Google 本身就没给头像,无需转存
if not google_avatar_url:
return
try:
# 执行耗时的网络 I/O:下载并上传到 S3
s3_avatar_url = await upload_google_avatar_to_s3(google_avatar_url, user_id)
# 成功后,开启一个独立的数据库连接写回数据
db_task = SessionLocal()
try:
user = db_task.query(User).filter(User.id == user_id).first()
if user:
user.avatar = s3_avatar_url
db_task.commit()
print(f"成功在后台为用户 {user_id} 转存头像到 S3")
finally:
db_task.close() # 必须手动关闭连接
except Exception as s3_err:
# 如果 S3 依然失败,打印错误日志,但此时绝不会打扰到已经登录的用户
print(f"Background S3 Upload failed for user {user_id}: {s3_err}")
@router.get("/login/google")
def login_google():
state = secrets.token_urlsafe(16)
url = (
f"{GOOGLE_AUTH_URL}"
f"?client_id={GOOGLE_CLIENT_ID}"
f"&redirect_uri={GOOGLE_REDIRECT_URI}"
f"&response_type=code"
f"&scope=openid email profile"
f"&state={state}"
)
response = RedirectResponse(url)
response.set_cookie(
"oauth_state",
state,
httponly=True,
secure=True,
samesite="lax"
)
return response
@router.get("/auth/callback")
async def auth_callback(
request: Request,
code: str,
state: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
cookie_state = request.cookies.get("oauth_state")
if not cookie_state or cookie_state != state:
raise HTTPException(status_code=400, detail="Invalid state")
async with httpx.AsyncClient() as client:
token_res = await client.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uri": GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
},
)
token_json = token_res.json()
access_token = token_json["access_token"]
user_res = await client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
user_info = user_res.json()
user = db.query(User).filter(User.email == user_info["email"]).first()
if not user:
# 提取邮箱前缀作为 handle 基础
# 比如 "john.doe@gmail.com" -> "john_doe"
base_handle = user_info["email"].split("@")[0].replace(".", "_").lower()
# 拼接一个随机短字符串,确保 handle 的唯一性
import secrets
random_suffix = secrets.token_hex(3) # 生成 6 位随机字符
suggested_handle = f"{base_handle}_{random_suffix}"
# 如果 Google 没有头像,使用 DiceBear 头像生成器作为兜底
google_avatar = user_info.get("picture")
if not google_avatar:
default_avatar_url = f"https://api.dicebear.com/7.x/lorelei/svg?seed={base_handle}"
else:
default_avatar_url = google_avatar
user = User(
google_id=user_info["id"],
email=user_info["email"],
name=user_info["name"],
avatar=default_avatar_url,
handle=suggested_handle,
)
db.add(user)
db.commit()
db.refresh(user)
# 异步/同步将头像转存到 S3
# FastAPI 会在把下方的 return 响应发送给浏览器之后,立刻在后台启动这个任务
if google_avatar:
background_tasks.add_task(
async_upload_avatar_task,
google_avatar,
str(user.id)
)
# 这里不需要再次 refresh,除非后面还要用到更新后的 user 对象
# db.refresh(user)
jwt_token = create_jwt_token({
"sub": user.id,
"email": user.email,
})
refresh_token = create_refresh_token({
"sub": user.id,
"email": user.email,
})
response = RedirectResponse(url=f"{FRONTEND_URL}")
response.set_cookie(
key="access_token",
value=jwt_token,
httponly=True,
max_age=ACCESS_TOKEN_MAX_AGE,
samesite="lax",
secure=IS_PROD,
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
max_age=REFRESH_TOKEN_MAX_AGE,
samesite="lax",
secure=IS_PROD,
)
response.set_cookie(
key="user_info",
value=quote(json.dumps({
"email": user.email,
"name": user.name,
"avatar": user.avatar
})),
max_age=ACCESS_TOKEN_MAX_AGE,
httponly=False
)
response.delete_cookie("oauth_state")
return response
+4 -1
View File
@@ -1,7 +1,10 @@
import app.auth.jwt as jwt
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import os import os
import jwt
from fastapi import HTTPException, status from fastapi import HTTPException, status
from dotenv import load_dotenv
load_dotenv()
JWT_SECRET = os.getenv("JWT_SECRET") JWT_SECRET = os.getenv("JWT_SECRET")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+5
View File
@@ -5,12 +5,17 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"alembic>=1.18.4",
"boto3>=1.43.9", "boto3>=1.43.9",
"dotenv>=0.9.9",
"fastapi>=0.136.1", "fastapi>=0.136.1",
"httpx>=0.28.1", "httpx>=0.28.1",
"oss2>=2.19.1", "oss2>=2.19.1",
"psycopg2-binary>=2.9.12",
"pyjwt[crypto]>=2.12.1",
"python-jose>=3.5.0", "python-jose>=3.5.0",
"sqlalchemy>=2.0.49", "sqlalchemy>=2.0.49",
"strawberry-graphql[fastapi]>=0.315.5",
"ulid-py>=1.1.0", "ulid-py>=1.1.0",
"uvicorn>=0.47.0", "uvicorn>=0.47.0",
] ]
Generated
+180
View File
@@ -2,6 +2,20 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.14" requires-python = ">=3.14"
[[package]]
name = "alembic"
version = "1.18.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
]
[[package]] [[package]]
name = "aliyun-python-sdk-core" name = "aliyun-python-sdk-core"
version = "2.16.0" version = "2.16.0"
@@ -129,24 +143,34 @@ name = "character-roleplay-backend"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "alembic" },
{ name = "boto3" }, { name = "boto3" },
{ name = "dotenv" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "oss2" }, { name = "oss2" },
{ name = "psycopg2-binary" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-jose" }, { name = "python-jose" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "strawberry-graphql", extra = ["fastapi"] },
{ name = "ulid-py" }, { name = "ulid-py" },
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.18.4" },
{ name = "boto3", specifier = ">=1.43.9" }, { name = "boto3", specifier = ">=1.43.9" },
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "fastapi", specifier = ">=0.136.1" }, { name = "fastapi", specifier = ">=0.136.1" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "oss2", specifier = ">=2.19.1" }, { name = "oss2", specifier = ">=2.19.1" },
{ name = "psycopg2-binary", specifier = ">=2.9.12" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" },
{ name = "python-jose", specifier = ">=3.5.0" }, { name = "python-jose", specifier = ">=3.5.0" },
{ name = "sqlalchemy", specifier = ">=2.0.49" }, { name = "sqlalchemy", specifier = ">=2.0.49" },
{ name = "strawberry-graphql", extras = ["fastapi"], specifier = ">=0.315.5" },
{ name = "ulid-py", specifier = ">=1.1.0" }, { name = "ulid-py", specifier = ">=1.1.0" },
{ name = "uvicorn", specifier = ">=0.47.0" }, { name = "uvicorn", specifier = ">=0.47.0" },
] ]
@@ -219,6 +243,18 @@ version = "1.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" } sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" }
[[package]]
name = "cross-web"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/83/b5ef04565acc065387dda3a4fbf0c4cfb6bab805c81b66b2bc5b5ac9a282/cross_web-0.6.0.tar.gz", hash = "sha256:ae90570802615365ca1a781117b43bfd0d6cd3bf611649d24c3a206a82a693c9", size = 331315, upload-time = "2026-04-13T14:29:12.718Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/a2/dab06d9b80cb76c700883186a9a2e6fd103342c9b4def4d88f5787796e17/cross_web-0.6.0-py3-none-any.whl", hash = "sha256:bdebf0c08d02f3a48cf67b6904d3a6d8fd8cab2cd905592ab96ab00b259cd582", size = 24820, upload-time = "2026-04-13T14:29:11.198Z" },
]
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "48.0.0" version = "48.0.0"
@@ -272,6 +308,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
] ]
[[package]]
name = "dotenv"
version = "0.9.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dotenv" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
]
[[package]] [[package]]
name = "ecdsa" name = "ecdsa"
version = "0.19.2" version = "0.19.2"
@@ -300,6 +347,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
] ]
[[package]]
name = "graphql-core"
version = "3.2.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/68/c5/36aa96205c3ecbb3d34c7c24189e4553c7ca2ebc7e1dd07432339b980272/graphql_core-3.2.8.tar.gz", hash = "sha256:015457da5d996c924ddf57a43f4e959b0b94fb695b85ed4c29446e508ed65cf3", size = 513181, upload-time = "2026-03-05T19:55:37.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/41/cb887d9afc5dabd78feefe6ccbaf83ff423c206a7a1b7aeeac05120b2125/graphql_core-3.2.8-py3-none-any.whl", hash = "sha256:cbee07bee1b3ed5e531723685369039f32ff815ef60166686e0162f540f1520c", size = 207349, upload-time = "2026-03-05T19:55:35.911Z" },
]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.5.0" version = "3.5.0"
@@ -378,6 +434,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/cb/5f001272b6faeb23c1c9e0acc04d48eaaf5c862c17709d20e3469c6e0139/jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f", size = 24489, upload-time = "2020-05-12T22:03:45.643Z" }, { url = "https://files.pythonhosted.org/packages/07/cb/5f001272b6faeb23c1c9e0acc04d48eaaf5c862c17709d20e3469c6e0139/jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f", size = 24489, upload-time = "2020-05-12T22:03:45.643Z" },
] ]
[[package]]
name = "mako"
version = "1.3.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]] [[package]]
name = "oss2" name = "oss2"
version = "2.19.1" version = "2.19.1"
@@ -392,6 +490,34 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/df/b5/f2cb1950dda46ac2284d6c950489fdacd0e743c2d79a347924d3cc44b86f/oss2-2.19.1.tar.gz", hash = "sha256:a8ab9ee7eb99e88a7e1382edc6ea641d219d585a7e074e3776e9dec9473e59c1", size = 298845, upload-time = "2024-10-25T11:37:46.638Z" } sdist = { url = "https://files.pythonhosted.org/packages/df/b5/f2cb1950dda46ac2284d6c950489fdacd0e743c2d79a347924d3cc44b86f/oss2-2.19.1.tar.gz", hash = "sha256:a8ab9ee7eb99e88a7e1382edc6ea641d219d585a7e074e3776e9dec9473e59c1", size = 298845, upload-time = "2024-10-25T11:37:46.638Z" }
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "psycopg2-binary"
version = "2.9.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" },
{ url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" },
{ url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" },
{ url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" },
{ url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" },
{ url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" },
{ url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" },
{ url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" },
{ url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" },
]
[[package]] [[package]]
name = "pyasn1" name = "pyasn1"
version = "0.6.3" version = "0.6.3"
@@ -485,6 +611,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
] ]
[[package]]
name = "pyjwt"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@@ -497,6 +637,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
] ]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]] [[package]]
name = "python-jose" name = "python-jose"
version = "3.5.0" version = "3.5.0"
@@ -511,6 +660,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
] ]
[[package]]
name = "python-multipart"
version = "0.0.29"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.34.2" version = "2.34.2"
@@ -597,6 +755,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
] ]
[[package]]
name = "strawberry-graphql"
version = "0.315.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cross-web" },
{ name = "graphql-core" },
{ name = "packaging" },
{ name = "python-dateutil" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/9b/101940ee899959d4d220cc8beb4f41bce9c58830d1872df06f35cf92c457/strawberry_graphql-0.315.5.tar.gz", hash = "sha256:29a2f04479aba29f9f30ecdfce1ef9ee04acee3c434a4f6019249474cd649492", size = 222724, upload-time = "2026-05-14T10:46:16.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/19/e389140b3b50faf803b4817ae5a8c3c2d6737f062f4a0fe6778e929a405d/strawberry_graphql-0.315.5-py3-none-any.whl", hash = "sha256:073bc818a5f55951a9a6fbab40bbfa07c418d2c4151cc2aab24399bd14d9a51a", size = 325062, upload-time = "2026-05-14T10:46:18.672Z" },
]
[package.optional-dependencies]
fastapi = [
{ name = "fastapi" },
{ name = "python-multipart" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"