Spaces:
Running
Running
Commit ·
5a01a63
1
Parent(s): e463de2
feat: add tidb
Browse files- .gitignore +1 -0
- app/api/server.py +43 -11
- app/api/v1/auth.py +35 -47
- app/api/v1/batch.py +6 -1
- app/api/v1/chat.py +1 -1
- app/api/v1/convert.py +11 -2
- app/api/v1/csv_analysis.py +2 -4
- app/api/v1/database.py +6 -1
- app/api/v1/embeddings.py +2 -1
- app/api/v1/qr_generator.py +1 -1
- app/api/v1/router.py +22 -1
- app/api/v1/url_shortener.py +1 -1
- app/api/v1/vector_stores.py +10 -3
- app/api/v1/webhook_socket.py +8 -1
- app/config.py +6 -1
- app/core/auth/__init__.py +16 -15
- app/core/auth/deps.py +248 -93
- app/core/auth/models.py +48 -86
- app/core/auth/schemas.py +0 -8
- app/core/database/__init__.py +2 -2
- app/core/database/pool.py +1 -1
- app/core/redis_client.py +0 -1
- app/core/tidb_manager.py +340 -0
- app/core/vector_store/__init__.py +6 -7
- app/core/vector_store/deps.py +39 -19
- app/core/vector_store/models.py +9 -16
- app/models/domain.py +0 -1
- app/models/schemas.py +0 -1
- app/services/__init__.py +7 -7
- app/services/auth_service.py +195 -163
- app/services/csv_analysis_service.py +1 -1
- app/services/dataset_metadata_service.py +1 -1
- app/services/prompts/csv_system_prompt.py +0 -1
- app/services/py_sandbox.py +21 -21
- app/services/text_cleaner_service.py +2 -1
- app/services/url_shortener_service.py +344 -322
- app/services/vector_store_service.py +58 -47
- app/services/verify_service.py +1 -1
- pyproject.toml +4 -0
- requirements.txt +0 -2
.gitignore
CHANGED
|
@@ -9,6 +9,7 @@ data/
|
|
| 9 |
test_deploy_flow.py
|
| 10 |
|
| 11 |
.Python
|
|
|
|
| 12 |
build/
|
| 13 |
develop-eggs/
|
| 14 |
dist/
|
|
|
|
| 9 |
test_deploy_flow.py
|
| 10 |
|
| 11 |
.Python
|
| 12 |
+
hammer_tidb.py
|
| 13 |
build/
|
| 14 |
develop-eggs/
|
| 15 |
dist/
|
app/api/server.py
CHANGED
|
@@ -7,16 +7,17 @@ from fastapi import FastAPI, Request
|
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from fastapi.middleware.gzip import GZipMiddleware
|
| 9 |
|
|
|
|
|
|
|
| 10 |
from app.config import get_settings
|
| 11 |
from app.core.auth.deps import init_auth_db
|
| 12 |
from app.core.database import pool_manager
|
| 13 |
from app.core.logger import get_logger
|
| 14 |
-
from app.core.redis_client import
|
| 15 |
from app.core.scripts import load_scripts
|
|
|
|
| 16 |
from app.services.embeddings_service import EmbeddingService
|
| 17 |
from app.services.vector_store_service import VectorStoreService
|
| 18 |
-
from app.api.v1.router import api_v1_router
|
| 19 |
-
from app.api.v1.system import is_maintenance
|
| 20 |
|
| 21 |
_logger = get_logger(__name__)
|
| 22 |
_settings = get_settings()
|
|
@@ -43,13 +44,27 @@ async def _self_ping():
|
|
| 43 |
|
| 44 |
@asynccontextmanager
|
| 45 |
async def lifespan(app: FastAPI):
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
_logger.info("Initializing embedding service (loading 384-dim model)...")
|
| 55 |
loop = asyncio.get_running_loop()
|
|
@@ -72,6 +87,11 @@ async def lifespan(app: FastAPI):
|
|
| 72 |
await close_redis(redis)
|
| 73 |
await _vector_store_service.close_all()
|
| 74 |
await pool_manager.close_all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
def create_application() -> FastAPI:
|
|
@@ -101,6 +121,18 @@ def create_application() -> FastAPI:
|
|
| 101 |
allow_headers=["*"],
|
| 102 |
)
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
@app.middleware("http")
|
| 105 |
async def maintenance_middleware(request: Request, call_next):
|
| 106 |
if is_maintenance():
|
|
@@ -109,7 +141,7 @@ def create_application() -> FastAPI:
|
|
| 109 |
if method not in ("GET", "HEAD", "OPTIONS"):
|
| 110 |
if not path.startswith("/api/v1/maintenance"):
|
| 111 |
if method == "POST" and path.startswith("/api/v1/backup"):
|
| 112 |
-
pass
|
| 113 |
else:
|
| 114 |
from starlette.responses import JSONResponse
|
| 115 |
return JSONResponse(
|
|
|
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from fastapi.middleware.gzip import GZipMiddleware
|
| 9 |
|
| 10 |
+
from app.api.v1.router import api_v1_router
|
| 11 |
+
from app.api.v1.system import is_maintenance
|
| 12 |
from app.config import get_settings
|
| 13 |
from app.core.auth.deps import init_auth_db
|
| 14 |
from app.core.database import pool_manager
|
| 15 |
from app.core.logger import get_logger
|
| 16 |
+
from app.core.redis_client import close_redis, create_redis_client
|
| 17 |
from app.core.scripts import load_scripts
|
| 18 |
+
from app.core.tidb_manager import TiDBWriteError
|
| 19 |
from app.services.embeddings_service import EmbeddingService
|
| 20 |
from app.services.vector_store_service import VectorStoreService
|
|
|
|
|
|
|
| 21 |
|
| 22 |
_logger = get_logger(__name__)
|
| 23 |
_settings = get_settings()
|
|
|
|
| 44 |
|
| 45 |
@asynccontextmanager
|
| 46 |
async def lifespan(app: FastAPI):
|
| 47 |
+
if not _settings.tidb_instance_list:
|
| 48 |
+
_logger.error(
|
| 49 |
+
"No TiDB instances configured! Set TIDB_INSTANCES environment variable "
|
| 50 |
+
"with comma-separated MySQL connection strings."
|
| 51 |
+
)
|
| 52 |
+
else:
|
| 53 |
+
_logger.info(
|
| 54 |
+
"Initializing TiDB databases across %d instance(s)...",
|
| 55 |
+
len(_settings.tidb_instance_list),
|
| 56 |
+
)
|
| 57 |
+
await init_auth_db()
|
| 58 |
+
_logger.info("Authentication database initialized on all TiDB instances")
|
| 59 |
+
|
| 60 |
+
_logger.info("Initializing vector store database...")
|
| 61 |
+
from app.core.vector_store.deps import init_vector_store_db
|
| 62 |
+
await init_vector_store_db()
|
| 63 |
+
await _vector_store_service.init_db()
|
| 64 |
+
_logger.info(
|
| 65 |
+
"Vector store database initialized with %d stores",
|
| 66 |
+
len(_vector_store_service.list_stores()),
|
| 67 |
+
)
|
| 68 |
|
| 69 |
_logger.info("Initializing embedding service (loading 384-dim model)...")
|
| 70 |
loop = asyncio.get_running_loop()
|
|
|
|
| 87 |
await close_redis(redis)
|
| 88 |
await _vector_store_service.close_all()
|
| 89 |
await pool_manager.close_all()
|
| 90 |
+
from app.core.tidb_manager import get_tidb_manager
|
| 91 |
+
mgr = get_tidb_manager()
|
| 92 |
+
if mgr:
|
| 93 |
+
await mgr.close_all_pools()
|
| 94 |
+
_logger.info("TiDB connection pools closed")
|
| 95 |
|
| 96 |
|
| 97 |
def create_application() -> FastAPI:
|
|
|
|
| 121 |
allow_headers=["*"],
|
| 122 |
)
|
| 123 |
|
| 124 |
+
@app.exception_handler(TiDBWriteError)
|
| 125 |
+
async def tidb_write_error_handler(request: Request, exc: TiDBWriteError):
|
| 126 |
+
from starlette.responses import JSONResponse
|
| 127 |
+
return JSONResponse(
|
| 128 |
+
status_code=503,
|
| 129 |
+
content={
|
| 130 |
+
"success": False,
|
| 131 |
+
"detail": "Failed to process request due to database storage limit. "
|
| 132 |
+
"Please try again later or contact support.",
|
| 133 |
+
},
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
@app.middleware("http")
|
| 137 |
async def maintenance_middleware(request: Request, call_next):
|
| 138 |
if is_maintenance():
|
|
|
|
| 141 |
if method not in ("GET", "HEAD", "OPTIONS"):
|
| 142 |
if not path.startswith("/api/v1/maintenance"):
|
| 143 |
if method == "POST" and path.startswith("/api/v1/backup"):
|
| 144 |
+
pass
|
| 145 |
else:
|
| 146 |
from starlette.responses import JSONResponse
|
| 147 |
return JSONResponse(
|
app/api/v1/auth.py
CHANGED
|
@@ -3,10 +3,9 @@ from __future__ import annotations
|
|
| 3 |
from typing import Annotated
|
| 4 |
|
| 5 |
from fastapi import APIRouter, Depends, Request, status
|
| 6 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
|
| 8 |
from app.config import get_settings
|
| 9 |
-
from app.core.auth.deps import get_current_user, get_db,
|
| 10 |
from app.core.auth.models import User
|
| 11 |
from app.core.auth.schemas import (
|
| 12 |
ChangePasswordSchema,
|
|
@@ -25,142 +24,134 @@ from app.core.auth.schemas import (
|
|
| 25 |
UserSchemaField,
|
| 26 |
UserSchemaResponse,
|
| 27 |
)
|
|
|
|
| 28 |
from app.services.auth_service import AuthService
|
| 29 |
|
| 30 |
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 31 |
_settings = get_settings()
|
| 32 |
|
| 33 |
|
| 34 |
-
def _build(data,
|
| 35 |
result = {"success": True, "data": data}
|
| 36 |
-
if
|
| 37 |
-
result["application_id"] =
|
| 38 |
-
if warning:
|
| 39 |
-
result["warning"] = warning
|
| 40 |
return result
|
| 41 |
|
| 42 |
|
| 43 |
@router.post("/register", response_model=ProfileResponse, status_code=status.HTTP_201_CREATED)
|
| 44 |
async def register(
|
| 45 |
schema: RegisterSchema,
|
| 46 |
-
db: Annotated[
|
| 47 |
_: Annotated[bool, Depends(require_application_id)],
|
| 48 |
):
|
| 49 |
user = await AuthService.register(db, schema)
|
| 50 |
-
|
| 51 |
-
return _build(
|
| 52 |
|
| 53 |
|
| 54 |
@router.post("/login", response_model=TokenResponse)
|
| 55 |
async def login(
|
| 56 |
schema: LoginSchema,
|
| 57 |
request: Request,
|
| 58 |
-
db: Annotated[
|
| 59 |
_: Annotated[bool, Depends(require_application_id)],
|
| 60 |
):
|
| 61 |
tokens = await AuthService.login(db, request, schema)
|
| 62 |
-
|
| 63 |
-
return _build(tokens.model_dump(), warning)
|
| 64 |
|
| 65 |
|
| 66 |
@router.post("/refresh", response_model=TokenResponse)
|
| 67 |
async def refresh_token(
|
| 68 |
schema: TokenRefreshSchema,
|
| 69 |
-
db: Annotated[
|
| 70 |
_: Annotated[bool, Depends(require_application_id)],
|
| 71 |
):
|
| 72 |
tokens = await AuthService.refresh(db, schema.refresh_token)
|
| 73 |
-
|
| 74 |
-
return _build(tokens.model_dump(), warning)
|
| 75 |
|
| 76 |
|
| 77 |
@router.post("/logout", response_model=MessageDataResponse)
|
| 78 |
async def logout(
|
| 79 |
schema: TokenRefreshSchema,
|
| 80 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 81 |
-
db: Annotated[
|
| 82 |
_: Annotated[bool, Depends(require_application_id)],
|
| 83 |
):
|
| 84 |
await AuthService.logout(db, current_user, schema.refresh_token)
|
| 85 |
-
|
| 86 |
-
return _build({"message": "Logged out successfully"}, warning)
|
| 87 |
|
| 88 |
|
| 89 |
@router.post("/logout-all", response_model=MessageDataResponse)
|
| 90 |
async def logout_all(
|
| 91 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 92 |
-
db: Annotated[
|
| 93 |
_: Annotated[bool, Depends(require_application_id)],
|
| 94 |
):
|
| 95 |
await AuthService.logout_all(db, current_user)
|
| 96 |
-
|
| 97 |
-
return _build({"message": "All sessions revoked"}, warning)
|
| 98 |
|
| 99 |
|
| 100 |
@router.post("/forgot-password", response_model=MessageDataResponse)
|
| 101 |
async def forgot_password(
|
| 102 |
schema: ForgotPasswordSchema,
|
| 103 |
-
db: Annotated[
|
| 104 |
_: Annotated[bool, Depends(require_application_id)],
|
| 105 |
):
|
| 106 |
await AuthService.forgot_password(db, schema)
|
| 107 |
-
|
| 108 |
-
return _build({"message": "If that email exists, a password reset link has been sent."}, warning)
|
| 109 |
|
| 110 |
|
| 111 |
@router.post("/reset-password", response_model=MessageDataResponse)
|
| 112 |
async def reset_password(
|
| 113 |
schema: ResetPasswordSchema,
|
| 114 |
-
db: Annotated[
|
| 115 |
_: Annotated[bool, Depends(require_application_id)],
|
| 116 |
):
|
| 117 |
await AuthService.reset_password(db, schema)
|
| 118 |
-
|
| 119 |
-
return _build({"message": "Password reset successfully"}, warning)
|
| 120 |
|
| 121 |
|
| 122 |
@router.post("/change-password", response_model=MessageDataResponse)
|
| 123 |
async def change_password(
|
| 124 |
schema: ChangePasswordSchema,
|
| 125 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 126 |
-
db: Annotated[
|
| 127 |
_: Annotated[bool, Depends(require_application_id)],
|
| 128 |
):
|
| 129 |
await AuthService.change_password(db, current_user, schema)
|
| 130 |
-
|
| 131 |
-
return _build({"message": "Password changed successfully"}, warning)
|
| 132 |
|
| 133 |
|
| 134 |
@router.get("/me", response_model=ProfileResponse)
|
| 135 |
async def get_me(
|
| 136 |
current_user: Annotated[User, Depends(get_current_user)],
|
|
|
|
| 137 |
_: Annotated[bool, Depends(require_application_id)],
|
| 138 |
):
|
| 139 |
-
|
| 140 |
-
return _build(
|
| 141 |
|
| 142 |
|
| 143 |
@router.patch("/me", response_model=ProfileResponse)
|
| 144 |
async def update_me(
|
| 145 |
schema: UpdateProfileSchema,
|
| 146 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 147 |
-
db: Annotated[
|
| 148 |
_: Annotated[bool, Depends(require_application_id)],
|
| 149 |
):
|
| 150 |
user = await AuthService.update_profile(db, current_user, schema)
|
| 151 |
-
|
| 152 |
-
return _build(
|
| 153 |
|
| 154 |
|
| 155 |
@router.delete("/me", response_model=MessageDataResponse)
|
| 156 |
async def delete_me(
|
| 157 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 158 |
-
db: Annotated[
|
| 159 |
_: Annotated[bool, Depends(require_application_id)],
|
| 160 |
):
|
| 161 |
await AuthService.soft_delete(db, current_user)
|
| 162 |
-
|
| 163 |
-
return _build({"message": "Account deleted successfully"}, warning)
|
| 164 |
|
| 165 |
|
| 166 |
@router.get("/schema", response_model=SchemaResponse)
|
|
@@ -185,28 +176,25 @@ async def get_user_schema(
|
|
| 185 |
UserSchemaField(field="deleted_at", type="datetime (ISO 8601)", required=False, description="Soft delete timestamp", constraints="Nullable"),
|
| 186 |
UserSchemaField(field="roles", type="array[string]", required=False, description="Assigned role names", constraints="Via user_roles association table"),
|
| 187 |
]
|
| 188 |
-
|
| 189 |
-
return _build(UserSchemaResponse(table_name="users", columns=columns).model_dump(), warning)
|
| 190 |
|
| 191 |
|
| 192 |
@router.get("/sessions", response_model=SessionListResponse)
|
| 193 |
async def list_sessions(
|
| 194 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 195 |
-
db: Annotated[
|
| 196 |
_: Annotated[bool, Depends(require_application_id)],
|
| 197 |
):
|
| 198 |
sessions = await AuthService.list_sessions(db, current_user)
|
| 199 |
-
|
| 200 |
-
return _build([SessionOut.model_validate(s).model_dump() for s in sessions], warning)
|
| 201 |
|
| 202 |
|
| 203 |
@router.delete("/sessions/{session_id}", response_model=MessageDataResponse)
|
| 204 |
async def revoke_session(
|
| 205 |
session_id: str,
|
| 206 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 207 |
-
db: Annotated[
|
| 208 |
_: Annotated[bool, Depends(require_application_id)],
|
| 209 |
):
|
| 210 |
await AuthService.revoke_session(db, current_user, session_id)
|
| 211 |
-
|
| 212 |
-
return _build({"message": "Session revoked successfully"}, warning)
|
|
|
|
| 3 |
from typing import Annotated
|
| 4 |
|
| 5 |
from fastapi import APIRouter, Depends, Request, status
|
|
|
|
| 6 |
|
| 7 |
from app.config import get_settings
|
| 8 |
+
from app.core.auth.deps import get_current_user, get_db, require_application_id
|
| 9 |
from app.core.auth.models import User
|
| 10 |
from app.core.auth.schemas import (
|
| 11 |
ChangePasswordSchema,
|
|
|
|
| 24 |
UserSchemaField,
|
| 25 |
UserSchemaResponse,
|
| 26 |
)
|
| 27 |
+
from app.core.tidb_manager import TiDBManager
|
| 28 |
from app.services.auth_service import AuthService
|
| 29 |
|
| 30 |
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 31 |
_settings = get_settings()
|
| 32 |
|
| 33 |
|
| 34 |
+
def _build(data, application_id=None):
|
| 35 |
result = {"success": True, "data": data}
|
| 36 |
+
if application_id:
|
| 37 |
+
result["application_id"] = application_id
|
|
|
|
|
|
|
| 38 |
return result
|
| 39 |
|
| 40 |
|
| 41 |
@router.post("/register", response_model=ProfileResponse, status_code=status.HTTP_201_CREATED)
|
| 42 |
async def register(
|
| 43 |
schema: RegisterSchema,
|
| 44 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 45 |
_: Annotated[bool, Depends(require_application_id)],
|
| 46 |
):
|
| 47 |
user = await AuthService.register(db, schema)
|
| 48 |
+
profile = await AuthService.user_to_profile(db, user)
|
| 49 |
+
return _build(profile.model_dump(), _settings.application_id or None)
|
| 50 |
|
| 51 |
|
| 52 |
@router.post("/login", response_model=TokenResponse)
|
| 53 |
async def login(
|
| 54 |
schema: LoginSchema,
|
| 55 |
request: Request,
|
| 56 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 57 |
_: Annotated[bool, Depends(require_application_id)],
|
| 58 |
):
|
| 59 |
tokens = await AuthService.login(db, request, schema)
|
| 60 |
+
return _build(tokens.model_dump(), _settings.application_id or None)
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
@router.post("/refresh", response_model=TokenResponse)
|
| 64 |
async def refresh_token(
|
| 65 |
schema: TokenRefreshSchema,
|
| 66 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 67 |
_: Annotated[bool, Depends(require_application_id)],
|
| 68 |
):
|
| 69 |
tokens = await AuthService.refresh(db, schema.refresh_token)
|
| 70 |
+
return _build(tokens.model_dump(), _settings.application_id or None)
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
@router.post("/logout", response_model=MessageDataResponse)
|
| 74 |
async def logout(
|
| 75 |
schema: TokenRefreshSchema,
|
| 76 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 77 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 78 |
_: Annotated[bool, Depends(require_application_id)],
|
| 79 |
):
|
| 80 |
await AuthService.logout(db, current_user, schema.refresh_token)
|
| 81 |
+
return _build({"message": "Logged out successfully"}, _settings.application_id or None)
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
@router.post("/logout-all", response_model=MessageDataResponse)
|
| 85 |
async def logout_all(
|
| 86 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 87 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 88 |
_: Annotated[bool, Depends(require_application_id)],
|
| 89 |
):
|
| 90 |
await AuthService.logout_all(db, current_user)
|
| 91 |
+
return _build({"message": "All sessions revoked"}, _settings.application_id or None)
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
@router.post("/forgot-password", response_model=MessageDataResponse)
|
| 95 |
async def forgot_password(
|
| 96 |
schema: ForgotPasswordSchema,
|
| 97 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 98 |
_: Annotated[bool, Depends(require_application_id)],
|
| 99 |
):
|
| 100 |
await AuthService.forgot_password(db, schema)
|
| 101 |
+
return _build({"message": "If that email exists, a password reset link has been sent."}, _settings.application_id or None)
|
|
|
|
| 102 |
|
| 103 |
|
| 104 |
@router.post("/reset-password", response_model=MessageDataResponse)
|
| 105 |
async def reset_password(
|
| 106 |
schema: ResetPasswordSchema,
|
| 107 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 108 |
_: Annotated[bool, Depends(require_application_id)],
|
| 109 |
):
|
| 110 |
await AuthService.reset_password(db, schema)
|
| 111 |
+
return _build({"message": "Password reset successfully"}, _settings.application_id or None)
|
|
|
|
| 112 |
|
| 113 |
|
| 114 |
@router.post("/change-password", response_model=MessageDataResponse)
|
| 115 |
async def change_password(
|
| 116 |
schema: ChangePasswordSchema,
|
| 117 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 118 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 119 |
_: Annotated[bool, Depends(require_application_id)],
|
| 120 |
):
|
| 121 |
await AuthService.change_password(db, current_user, schema)
|
| 122 |
+
return _build({"message": "Password changed successfully"}, _settings.application_id or None)
|
|
|
|
| 123 |
|
| 124 |
|
| 125 |
@router.get("/me", response_model=ProfileResponse)
|
| 126 |
async def get_me(
|
| 127 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 128 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 129 |
_: Annotated[bool, Depends(require_application_id)],
|
| 130 |
):
|
| 131 |
+
profile = await AuthService.user_to_profile(db, current_user)
|
| 132 |
+
return _build(profile.model_dump(), _settings.application_id or None)
|
| 133 |
|
| 134 |
|
| 135 |
@router.patch("/me", response_model=ProfileResponse)
|
| 136 |
async def update_me(
|
| 137 |
schema: UpdateProfileSchema,
|
| 138 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 139 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 140 |
_: Annotated[bool, Depends(require_application_id)],
|
| 141 |
):
|
| 142 |
user = await AuthService.update_profile(db, current_user, schema)
|
| 143 |
+
profile = await AuthService.user_to_profile(db, user)
|
| 144 |
+
return _build(profile.model_dump(), _settings.application_id or None)
|
| 145 |
|
| 146 |
|
| 147 |
@router.delete("/me", response_model=MessageDataResponse)
|
| 148 |
async def delete_me(
|
| 149 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 150 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 151 |
_: Annotated[bool, Depends(require_application_id)],
|
| 152 |
):
|
| 153 |
await AuthService.soft_delete(db, current_user)
|
| 154 |
+
return _build({"message": "Account deleted successfully"}, _settings.application_id or None)
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
@router.get("/schema", response_model=SchemaResponse)
|
|
|
|
| 176 |
UserSchemaField(field="deleted_at", type="datetime (ISO 8601)", required=False, description="Soft delete timestamp", constraints="Nullable"),
|
| 177 |
UserSchemaField(field="roles", type="array[string]", required=False, description="Assigned role names", constraints="Via user_roles association table"),
|
| 178 |
]
|
| 179 |
+
return _build(UserSchemaResponse(table_name="users", columns=columns).model_dump(), _settings.application_id or None)
|
|
|
|
| 180 |
|
| 181 |
|
| 182 |
@router.get("/sessions", response_model=SessionListResponse)
|
| 183 |
async def list_sessions(
|
| 184 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 185 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 186 |
_: Annotated[bool, Depends(require_application_id)],
|
| 187 |
):
|
| 188 |
sessions = await AuthService.list_sessions(db, current_user)
|
| 189 |
+
return _build([SessionOut.model_validate(s).model_dump() for s in sessions], _settings.application_id or None)
|
|
|
|
| 190 |
|
| 191 |
|
| 192 |
@router.delete("/sessions/{session_id}", response_model=MessageDataResponse)
|
| 193 |
async def revoke_session(
|
| 194 |
session_id: str,
|
| 195 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 196 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 197 |
_: Annotated[bool, Depends(require_application_id)],
|
| 198 |
):
|
| 199 |
await AuthService.revoke_session(db, current_user, session_id)
|
| 200 |
+
return _build({"message": "Session revoked successfully"}, _settings.application_id or None)
|
|
|
app/api/v1/batch.py
CHANGED
|
@@ -9,7 +9,12 @@ from urllib.parse import urlparse
|
|
| 9 |
import httpx
|
| 10 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
| 11 |
|
| 12 |
-
from app.api.deps import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from app.api.v1.convert import _build_metadata, _thread_pool
|
| 14 |
from app.config import get_settings
|
| 15 |
from app.core.logger import get_logger
|
|
|
|
| 9 |
import httpx
|
| 10 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
| 11 |
|
| 12 |
+
from app.api.deps import (
|
| 13 |
+
get_converter_service,
|
| 14 |
+
get_extraction_service,
|
| 15 |
+
get_text_cleaner_service,
|
| 16 |
+
require_auth,
|
| 17 |
+
)
|
| 18 |
from app.api.v1.convert import _build_metadata, _thread_pool
|
| 19 |
from app.config import get_settings
|
| 20 |
from app.core.logger import get_logger
|
app/api/v1/chat.py
CHANGED
|
@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|
| 9 |
from fastapi.responses import StreamingResponse
|
| 10 |
|
| 11 |
from app.api.deps import require_auth
|
| 12 |
-
from app.services.chat_service import
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
|
|
|
| 9 |
from fastapi.responses import StreamingResponse
|
| 10 |
|
| 11 |
from app.api.deps import require_auth
|
| 12 |
+
from app.services.chat_service import _stream_chat_completion, chat_completion
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
app/api/v1/convert.py
CHANGED
|
@@ -9,16 +9,25 @@ from typing import Annotated, Any, Dict, Optional
|
|
| 9 |
from urllib.parse import urlparse
|
| 10 |
|
| 11 |
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
from fastapi.responses import PlainTextResponse
|
| 13 |
-
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
| 14 |
|
| 15 |
-
from app.config import get_settings
|
| 16 |
from app.api.deps import (
|
| 17 |
get_converter_service,
|
| 18 |
get_extraction_service,
|
| 19 |
get_text_cleaner_service,
|
| 20 |
require_auth,
|
| 21 |
)
|
|
|
|
| 22 |
from app.core.logger import get_logger
|
| 23 |
from app.models.domain import ConversionError, count_tokens
|
| 24 |
from app.models.schemas import ConversionMetadata, ConversionResponse, UrlRequest
|
|
|
|
| 9 |
from urllib.parse import urlparse
|
| 10 |
|
| 11 |
import httpx
|
| 12 |
+
from fastapi import (
|
| 13 |
+
APIRouter,
|
| 14 |
+
Depends,
|
| 15 |
+
File,
|
| 16 |
+
Form,
|
| 17 |
+
HTTPException,
|
| 18 |
+
Query,
|
| 19 |
+
UploadFile,
|
| 20 |
+
status,
|
| 21 |
+
)
|
| 22 |
from fastapi.responses import PlainTextResponse
|
|
|
|
| 23 |
|
|
|
|
| 24 |
from app.api.deps import (
|
| 25 |
get_converter_service,
|
| 26 |
get_extraction_service,
|
| 27 |
get_text_cleaner_service,
|
| 28 |
require_auth,
|
| 29 |
)
|
| 30 |
+
from app.config import get_settings
|
| 31 |
from app.core.logger import get_logger
|
| 32 |
from app.models.domain import ConversionError, count_tokens
|
| 33 |
from app.models.schemas import ConversionMetadata, ConversionResponse, UrlRequest
|
app/api/v1/csv_analysis.py
CHANGED
|
@@ -3,15 +3,13 @@ from __future__ import annotations
|
|
| 3 |
import json
|
| 4 |
from typing import Annotated, Any, Dict, List, Optional
|
| 5 |
|
| 6 |
-
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
| 7 |
-
from pydantic import BaseModel,
|
| 8 |
|
| 9 |
from app.api.deps import require_auth
|
| 10 |
from app.config import get_settings
|
| 11 |
from app.services.chat_service import chat_completion
|
| 12 |
from app.services.csv_analysis_service import (
|
| 13 |
-
analyze_csv_dataset,
|
| 14 |
-
create_csv_chart,
|
| 15 |
execute_csv_chat_blocks,
|
| 16 |
get_dataset_info,
|
| 17 |
)
|
|
|
|
| 3 |
import json
|
| 4 |
from typing import Annotated, Any, Dict, List, Optional
|
| 5 |
|
| 6 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
| 7 |
+
from pydantic import BaseModel, ValidationError
|
| 8 |
|
| 9 |
from app.api.deps import require_auth
|
| 10 |
from app.config import get_settings
|
| 11 |
from app.services.chat_service import chat_completion
|
| 12 |
from app.services.csv_analysis_service import (
|
|
|
|
|
|
|
| 13 |
execute_csv_chat_blocks,
|
| 14 |
get_dataset_info,
|
| 15 |
)
|
app/api/v1/database.py
CHANGED
|
@@ -6,7 +6,12 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|
| 6 |
|
| 7 |
from app.api.deps import require_auth
|
| 8 |
from app.core.logger import get_logger
|
| 9 |
-
from app.models.schemas import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from app.services.database_service import DatabaseService
|
| 11 |
|
| 12 |
router = APIRouter()
|
|
|
|
| 6 |
|
| 7 |
from app.api.deps import require_auth
|
| 8 |
from app.core.logger import get_logger
|
| 9 |
+
from app.models.schemas import (
|
| 10 |
+
DatabaseQueryRequest,
|
| 11 |
+
DatabaseQueryResponse,
|
| 12 |
+
DatabaseValidateRequest,
|
| 13 |
+
DatabaseValidateResponse,
|
| 14 |
+
)
|
| 15 |
from app.services.database_service import DatabaseService
|
| 16 |
|
| 17 |
router = APIRouter()
|
app/api/v1/embeddings.py
CHANGED
|
@@ -4,9 +4,10 @@ import asyncio
|
|
| 4 |
import concurrent.futures
|
| 5 |
import os
|
| 6 |
import time
|
|
|
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
|
| 9 |
-
from app.api.deps import
|
| 10 |
from app.config import get_settings
|
| 11 |
from app.core.logger import get_logger
|
| 12 |
from app.models.schemas import EmbeddingItem, EmbeddingRequest, EmbeddingResponse
|
|
|
|
| 4 |
import concurrent.futures
|
| 5 |
import os
|
| 6 |
import time
|
| 7 |
+
|
| 8 |
from fastapi import APIRouter, Depends, HTTPException
|
| 9 |
|
| 10 |
+
from app.api.deps import get_embeddings_service, require_auth
|
| 11 |
from app.config import get_settings
|
| 12 |
from app.core.logger import get_logger
|
| 13 |
from app.models.schemas import EmbeddingItem, EmbeddingRequest, EmbeddingResponse
|
app/api/v1/qr_generator.py
CHANGED
|
@@ -6,7 +6,7 @@ from typing import List, Optional
|
|
| 6 |
from fastapi import APIRouter, HTTPException
|
| 7 |
from pydantic import BaseModel, Field, field_validator
|
| 8 |
|
| 9 |
-
from app.services.qr_generator_service import
|
| 10 |
|
| 11 |
router = APIRouter()
|
| 12 |
|
|
|
|
| 6 |
from fastapi import APIRouter, HTTPException
|
| 7 |
from pydantic import BaseModel, Field, field_validator
|
| 8 |
|
| 9 |
+
from app.services.qr_generator_service import QRGeneratorError, QRGeneratorService
|
| 10 |
|
| 11 |
router = APIRouter()
|
| 12 |
|
app/api/v1/router.py
CHANGED
|
@@ -2,7 +2,28 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from fastapi import APIRouter
|
| 4 |
|
| 5 |
-
from app.api.v1 import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from app.api.verify import router as verify_router
|
| 7 |
|
| 8 |
api_v1_router = APIRouter()
|
|
|
|
| 2 |
|
| 3 |
from fastapi import APIRouter
|
| 4 |
|
| 5 |
+
from app.api.v1 import (
|
| 6 |
+
auth,
|
| 7 |
+
batch,
|
| 8 |
+
chat,
|
| 9 |
+
code_executor,
|
| 10 |
+
convert,
|
| 11 |
+
csv_analysis,
|
| 12 |
+
database,
|
| 13 |
+
embeddings,
|
| 14 |
+
qr_generator,
|
| 15 |
+
reconcile,
|
| 16 |
+
scraper,
|
| 17 |
+
semantic_router,
|
| 18 |
+
sql_validator,
|
| 19 |
+
system,
|
| 20 |
+
token_counter,
|
| 21 |
+
token_generator,
|
| 22 |
+
url_shortener,
|
| 23 |
+
vector_stores,
|
| 24 |
+
web_search,
|
| 25 |
+
webhook_socket,
|
| 26 |
+
)
|
| 27 |
from app.api.verify import router as verify_router
|
| 28 |
|
| 29 |
api_v1_router = APIRouter()
|
app/api/v1/url_shortener.py
CHANGED
|
@@ -9,7 +9,6 @@ from pydantic import BaseModel, Field
|
|
| 9 |
|
| 10 |
from app.config import get_settings
|
| 11 |
from app.services.url_shortener_service import (
|
| 12 |
-
URLShortenerService,
|
| 13 |
AliasTakenError,
|
| 14 |
AuthenticationError,
|
| 15 |
AuthorizationError,
|
|
@@ -17,6 +16,7 @@ from app.services.url_shortener_service import (
|
|
| 17 |
LinkNotFoundError,
|
| 18 |
PlanLimitExceededError,
|
| 19 |
URLShortenerError,
|
|
|
|
| 20 |
ValidationError,
|
| 21 |
)
|
| 22 |
|
|
|
|
| 9 |
|
| 10 |
from app.config import get_settings
|
| 11 |
from app.services.url_shortener_service import (
|
|
|
|
| 12 |
AliasTakenError,
|
| 13 |
AuthenticationError,
|
| 14 |
AuthorizationError,
|
|
|
|
| 16 |
LinkNotFoundError,
|
| 17 |
PlanLimitExceededError,
|
| 18 |
URLShortenerError,
|
| 19 |
+
URLShortenerService,
|
| 20 |
ValidationError,
|
| 21 |
)
|
| 22 |
|
app/api/v1/vector_stores.py
CHANGED
|
@@ -4,10 +4,18 @@ import asyncio
|
|
| 4 |
import time
|
| 5 |
|
| 6 |
import httpx
|
| 7 |
-
from fastapi import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
from app.api.deps import get_vector_store_service, require_auth
|
| 10 |
-
from app.core.auth.deps import get_temp_db_warning
|
| 11 |
from app.core.logger import get_logger
|
| 12 |
from app.models.domain import ConversionError
|
| 13 |
from app.models.schemas import (
|
|
@@ -72,7 +80,6 @@ async def create_vector_store(
|
|
| 72 |
document_count=stats["document_count"],
|
| 73 |
created_at=stats["created_at"],
|
| 74 |
metadata=stats["metadata"],
|
| 75 |
-
warning=get_temp_db_warning(),
|
| 76 |
)
|
| 77 |
|
| 78 |
|
|
|
|
| 4 |
import time
|
| 5 |
|
| 6 |
import httpx
|
| 7 |
+
from fastapi import (
|
| 8 |
+
APIRouter,
|
| 9 |
+
Depends,
|
| 10 |
+
File,
|
| 11 |
+
Form,
|
| 12 |
+
HTTPException,
|
| 13 |
+
Query,
|
| 14 |
+
UploadFile,
|
| 15 |
+
status,
|
| 16 |
+
)
|
| 17 |
|
| 18 |
from app.api.deps import get_vector_store_service, require_auth
|
|
|
|
| 19 |
from app.core.logger import get_logger
|
| 20 |
from app.models.domain import ConversionError
|
| 21 |
from app.models.schemas import (
|
|
|
|
| 80 |
document_count=stats["document_count"],
|
| 81 |
created_at=stats["created_at"],
|
| 82 |
metadata=stats["metadata"],
|
|
|
|
| 83 |
)
|
| 84 |
|
| 85 |
|
app/api/v1/webhook_socket.py
CHANGED
|
@@ -4,7 +4,14 @@ import asyncio
|
|
| 4 |
import hmac as hmac_mod
|
| 5 |
import json
|
| 6 |
|
| 7 |
-
from fastapi import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
from app.api.deps import require_auth
|
| 10 |
from app.models.schemas import (
|
|
|
|
| 4 |
import hmac as hmac_mod
|
| 5 |
import json
|
| 6 |
|
| 7 |
+
from fastapi import (
|
| 8 |
+
APIRouter,
|
| 9 |
+
Depends,
|
| 10 |
+
HTTPException,
|
| 11 |
+
Request,
|
| 12 |
+
WebSocket,
|
| 13 |
+
WebSocketDisconnect,
|
| 14 |
+
)
|
| 15 |
|
| 16 |
from app.api.deps import require_auth
|
| 17 |
from app.models.schemas import (
|
app/config.py
CHANGED
|
@@ -73,7 +73,8 @@ class Settings(BaseSettings):
|
|
| 73 |
jwt_default_expiry_minutes: int = 30
|
| 74 |
jwt_issuer: str = "all-api-collection"
|
| 75 |
|
| 76 |
-
|
|
|
|
| 77 |
access_token_expire_minutes: int = 15
|
| 78 |
refresh_token_expire_days: int = 7
|
| 79 |
max_login_attempts: int = 5
|
|
@@ -81,6 +82,10 @@ class Settings(BaseSettings):
|
|
| 81 |
application_id: str = ""
|
| 82 |
admin_password: str = ""
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
@property
|
| 85 |
def max_upload_mb(self) -> int:
|
| 86 |
return self.max_upload_bytes // (1024 * 1024)
|
|
|
|
| 73 |
jwt_default_expiry_minutes: int = 30
|
| 74 |
jwt_issuer: str = "all-api-collection"
|
| 75 |
|
| 76 |
+
tidb_instances: str = ""
|
| 77 |
+
tidb_ssl: bool = True
|
| 78 |
access_token_expire_minutes: int = 15
|
| 79 |
refresh_token_expire_days: int = 7
|
| 80 |
max_login_attempts: int = 5
|
|
|
|
| 82 |
application_id: str = ""
|
| 83 |
admin_password: str = ""
|
| 84 |
|
| 85 |
+
@property
|
| 86 |
+
def tidb_instance_list(self) -> list[str]:
|
| 87 |
+
return [u.strip() for u in self.tidb_instances.split(",") if u.strip()]
|
| 88 |
+
|
| 89 |
@property
|
| 90 |
def max_upload_mb(self) -> int:
|
| 91 |
return self.max_upload_bytes // (1024 * 1024)
|
app/core/auth/__init__.py
CHANGED
|
@@ -1,27 +1,28 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from app.core.auth.
|
| 4 |
-
|
| 5 |
-
user_roles, role_permissions,
|
| 6 |
-
)
|
| 7 |
from app.core.auth.schemas import (
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
MessageResponse,
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
)
|
| 17 |
|
| 18 |
__all__ = [
|
| 19 |
-
"
|
| 20 |
-
"user_roles", "role_permissions",
|
| 21 |
"RegisterSchema", "LoginSchema", "TokenResponse", "TokenRefreshSchema",
|
| 22 |
"UserProfile", "UpdateProfileSchema", "ChangePasswordSchema",
|
| 23 |
"ForgotPasswordSchema", "ResetPasswordSchema", "SessionOut",
|
| 24 |
"MessageResponse", "UserSchemaResponse", "UserSchemaField",
|
| 25 |
"get_db", "get_current_user", "require_permissions",
|
| 26 |
-
"get_temp_db_warning", "TempDatabaseWarning",
|
| 27 |
]
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from app.core.auth.deps import get_current_user, get_db, require_permissions
|
| 4 |
+
from app.core.auth.models import Permission, RefreshSession, Role, User
|
|
|
|
|
|
|
| 5 |
from app.core.auth.schemas import (
|
| 6 |
+
ChangePasswordSchema,
|
| 7 |
+
ForgotPasswordSchema,
|
| 8 |
+
LoginSchema,
|
| 9 |
+
MessageResponse,
|
| 10 |
+
RegisterSchema,
|
| 11 |
+
ResetPasswordSchema,
|
| 12 |
+
SessionOut,
|
| 13 |
+
TokenRefreshSchema,
|
| 14 |
+
TokenResponse,
|
| 15 |
+
UpdateProfileSchema,
|
| 16 |
+
UserProfile,
|
| 17 |
+
UserSchemaField,
|
| 18 |
+
UserSchemaResponse,
|
| 19 |
)
|
| 20 |
|
| 21 |
__all__ = [
|
| 22 |
+
"User", "RefreshSession", "Role", "Permission",
|
|
|
|
| 23 |
"RegisterSchema", "LoginSchema", "TokenResponse", "TokenRefreshSchema",
|
| 24 |
"UserProfile", "UpdateProfileSchema", "ChangePasswordSchema",
|
| 25 |
"ForgotPasswordSchema", "ResetPasswordSchema", "SessionOut",
|
| 26 |
"MessageResponse", "UserSchemaResponse", "UserSchemaField",
|
| 27 |
"get_db", "get_current_user", "require_permissions",
|
|
|
|
| 28 |
]
|
app/core/auth/deps.py
CHANGED
|
@@ -1,117 +1,211 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
| 4 |
-
from
|
|
|
|
| 5 |
|
| 6 |
import jwt
|
| 7 |
from argon2 import PasswordHasher
|
| 8 |
from fastapi import Depends, HTTPException, Request, status
|
| 9 |
-
from sqlalchemy import select
|
| 10 |
-
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
| 11 |
|
| 12 |
from app.config import get_settings
|
| 13 |
-
from app.core.auth.models import
|
|
|
|
|
|
|
| 14 |
|
| 15 |
logger = logging.getLogger("auth")
|
| 16 |
|
| 17 |
_settings = get_settings()
|
| 18 |
-
|
| 19 |
-
_is_temp_db = (
|
| 20 |
-
"sqlite" in _settings.database_url
|
| 21 |
-
and "localhost" not in _settings.database_url
|
| 22 |
-
and "postgres" not in _settings.database_url.lower()
|
| 23 |
-
and "mysql" not in _settings.database_url.lower()
|
| 24 |
-
)
|
| 25 |
-
|
| 26 |
-
_raw_url: str = getattr(_settings, "database_url", None) or "sqlite+aiosqlite:///data/auth.db"
|
| 27 |
-
|
| 28 |
-
if "sqlite" in _raw_url and "sqlite+aiosqlite" not in _raw_url:
|
| 29 |
-
_raw_url = _raw_url.replace("sqlite:///", "sqlite+aiosqlite:///")
|
| 30 |
-
|
| 31 |
-
DATABASE_URL: str = _raw_url
|
| 32 |
-
|
| 33 |
-
engine = create_async_engine(DATABASE_URL, echo=False)
|
| 34 |
-
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
| 35 |
-
|
| 36 |
ph = PasswordHasher()
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
class TempDatabaseWarning:
|
| 40 |
-
code: str = "TEMP_DATABASE"
|
| 41 |
-
message: str = (
|
| 42 |
-
"No external database configuration was provided. "
|
| 43 |
-
"The service is currently using its built-in SQLite database intended for development and temporary use. "
|
| 44 |
-
"Data stored in this database should not be considered permanent."
|
| 45 |
-
)
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
(
|
| 70 |
-
("users:delete", "Delete users"),
|
| 71 |
-
("admin:access", "Access admin panel"),
|
| 72 |
-
]:
|
| 73 |
-
p = Permission(code=code, description=desc)
|
| 74 |
-
perms.append(p)
|
| 75 |
-
session.add(p)
|
| 76 |
-
|
| 77 |
-
sa_role.permissions.extend(perms)
|
| 78 |
-
admin_role.permissions.extend(perms[:-1])
|
| 79 |
-
user_role.permissions.append(perms[0])
|
| 80 |
-
|
| 81 |
-
await session.commit()
|
| 82 |
-
|
| 83 |
-
admin_email = "admin@example.com"
|
| 84 |
-
result = await session.execute(select(User).where(User.email == admin_email))
|
| 85 |
-
if not result.scalars().first():
|
| 86 |
-
admin_password = _settings.admin_password or "Admin123!"
|
| 87 |
-
admin_user = User(
|
| 88 |
-
email=admin_email,
|
| 89 |
-
full_name="System Administrator",
|
| 90 |
-
password_hash=ph.hash(admin_password),
|
| 91 |
-
is_verified=True,
|
| 92 |
)
|
| 93 |
-
role_result = await session.execute(select(Role).where(Role.name == "SuperAdmin"))
|
| 94 |
-
admin_role = role_result.scalars().first()
|
| 95 |
-
if admin_role:
|
| 96 |
-
admin_user.roles.append(admin_role)
|
| 97 |
-
session.add(admin_user)
|
| 98 |
-
await session.commit()
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
|
| 103 |
|
| 104 |
-
async def get_db() ->
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
| 110 |
|
| 111 |
|
| 112 |
async def get_current_user(
|
| 113 |
request: Request,
|
| 114 |
-
db: Annotated[
|
| 115 |
) -> User:
|
| 116 |
credentials_exception = HTTPException(
|
| 117 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
@@ -134,9 +228,14 @@ async def get_current_user(
|
|
| 134 |
except jwt.PyJWTError:
|
| 135 |
raise credentials_exception
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
raise credentials_exception
|
| 141 |
return user
|
| 142 |
|
|
@@ -164,10 +263,16 @@ def require_permissions(*required_perms: str):
|
|
| 164 |
user: Annotated[User, Depends(get_current_user)],
|
| 165 |
) -> User:
|
| 166 |
user_perms = set()
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
missing = [p for p in required_perms if p not in user_perms]
|
| 172 |
if missing:
|
| 173 |
raise HTTPException(
|
|
@@ -177,3 +282,53 @@ def require_permissions(*required_perms: str):
|
|
| 177 |
return user
|
| 178 |
|
| 179 |
return permission_checker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Annotated, Optional
|
| 6 |
|
| 7 |
import jwt
|
| 8 |
from argon2 import PasswordHasher
|
| 9 |
from fastapi import Depends, HTTPException, Request, status
|
|
|
|
|
|
|
| 10 |
|
| 11 |
from app.config import get_settings
|
| 12 |
+
from app.core.auth.models import RefreshSession, User, _uuid
|
| 13 |
+
from app.core.auth.models import _utcnow as _now
|
| 14 |
+
from app.core.tidb_manager import TiDBManager, get_tidb_manager, set_tidb_manager
|
| 15 |
|
| 16 |
logger = logging.getLogger("auth")
|
| 17 |
|
| 18 |
_settings = get_settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
ph = PasswordHasher()
|
| 20 |
|
| 21 |
+
AUTH_TABLES: dict[str, str] = {
|
| 22 |
+
"users": """
|
| 23 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 24 |
+
id VARCHAR(36) PRIMARY KEY,
|
| 25 |
+
email VARCHAR(255) NOT NULL,
|
| 26 |
+
username VARCHAR(50) NULL,
|
| 27 |
+
full_name VARCHAR(255) NULL,
|
| 28 |
+
password_hash TEXT NOT NULL,
|
| 29 |
+
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
| 30 |
+
is_verified TINYINT(1) NOT NULL DEFAULT 0,
|
| 31 |
+
failed_login_attempts INT NOT NULL DEFAULT 0,
|
| 32 |
+
locked_until DATETIME NULL,
|
| 33 |
+
last_login DATETIME NULL,
|
| 34 |
+
password_changed_at DATETIME NULL,
|
| 35 |
+
created_at DATETIME NOT NULL,
|
| 36 |
+
updated_at DATETIME NOT NULL,
|
| 37 |
+
deleted_at DATETIME NULL,
|
| 38 |
+
UNIQUE KEY uk_users_email (email),
|
| 39 |
+
UNIQUE KEY uk_users_username (username),
|
| 40 |
+
INDEX idx_users_email (email),
|
| 41 |
+
INDEX idx_users_username (username)
|
| 42 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 43 |
+
""",
|
| 44 |
+
"roles": """
|
| 45 |
+
CREATE TABLE IF NOT EXISTS roles (
|
| 46 |
+
id VARCHAR(36) PRIMARY KEY,
|
| 47 |
+
name VARCHAR(50) NOT NULL,
|
| 48 |
+
description VARCHAR(255) NULL,
|
| 49 |
+
UNIQUE KEY uk_roles_name (name)
|
| 50 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 51 |
+
""",
|
| 52 |
+
"permissions": """
|
| 53 |
+
CREATE TABLE IF NOT EXISTS permissions (
|
| 54 |
+
id VARCHAR(36) PRIMARY KEY,
|
| 55 |
+
code VARCHAR(100) NOT NULL,
|
| 56 |
+
description VARCHAR(255) NULL,
|
| 57 |
+
UNIQUE KEY uk_permissions_code (code),
|
| 58 |
+
INDEX idx_permissions_code (code)
|
| 59 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 60 |
+
""",
|
| 61 |
+
"user_roles": """
|
| 62 |
+
CREATE TABLE IF NOT EXISTS user_roles (
|
| 63 |
+
user_id VARCHAR(36) NOT NULL,
|
| 64 |
+
role_id VARCHAR(36) NOT NULL,
|
| 65 |
+
PRIMARY KEY (user_id, role_id),
|
| 66 |
+
CONSTRAINT fk_ur_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
| 67 |
+
CONSTRAINT fk_ur_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
|
| 68 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 69 |
+
""",
|
| 70 |
+
"role_permissions": """
|
| 71 |
+
CREATE TABLE IF NOT EXISTS role_permissions (
|
| 72 |
+
role_id VARCHAR(36) NOT NULL,
|
| 73 |
+
permission_id VARCHAR(36) NOT NULL,
|
| 74 |
+
PRIMARY KEY (role_id, permission_id),
|
| 75 |
+
CONSTRAINT fk_rp_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
| 76 |
+
CONSTRAINT fk_rp_perm FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
| 77 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 78 |
+
""",
|
| 79 |
+
"refresh_sessions": """
|
| 80 |
+
CREATE TABLE IF NOT EXISTS refresh_sessions (
|
| 81 |
+
id VARCHAR(36) PRIMARY KEY,
|
| 82 |
+
user_id VARCHAR(36) NOT NULL,
|
| 83 |
+
token_key VARCHAR(64) NOT NULL,
|
| 84 |
+
token_hash VARCHAR(255) NOT NULL,
|
| 85 |
+
device_info VARCHAR(255) NULL,
|
| 86 |
+
ip_address VARCHAR(45) NULL,
|
| 87 |
+
expires_at DATETIME NOT NULL,
|
| 88 |
+
revoked_at DATETIME NULL,
|
| 89 |
+
created_at DATETIME NOT NULL,
|
| 90 |
+
updated_at DATETIME NOT NULL,
|
| 91 |
+
UNIQUE KEY uk_rs_token_key (token_key),
|
| 92 |
+
INDEX idx_rs_user_id (user_id),
|
| 93 |
+
INDEX idx_rs_token_key (token_key),
|
| 94 |
+
CONSTRAINT fk_rs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 95 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 96 |
+
""",
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _create_manager() -> Optional[TiDBManager]:
|
| 101 |
+
urls = _settings.tidb_instance_list
|
| 102 |
+
if not urls:
|
| 103 |
+
logger.warning("No TiDB instances configured (TIDB_INSTANCES is empty)")
|
| 104 |
+
return None
|
| 105 |
+
import ssl
|
| 106 |
+
if _settings.tidb_ssl:
|
| 107 |
+
ctx = ssl.create_default_context()
|
| 108 |
+
ctx.check_hostname = False
|
| 109 |
+
ctx.verify_mode = ssl.CERT_NONE
|
| 110 |
+
else:
|
| 111 |
+
ctx = None
|
| 112 |
+
mgr = TiDBManager(urls, ssl=ctx)
|
| 113 |
+
return mgr
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
+
async def init_auth_db():
|
| 117 |
+
mgr = _create_manager()
|
| 118 |
+
if mgr is None:
|
| 119 |
+
logger.error("Cannot initialize auth DB: no TiDB instances configured")
|
| 120 |
+
return
|
| 121 |
+
await mgr.initialize_pools()
|
| 122 |
+
set_tidb_manager(mgr)
|
| 123 |
+
|
| 124 |
+
await mgr.ensure_tables(AUTH_TABLES)
|
| 125 |
+
|
| 126 |
+
roles_result = await mgr.fetchone("SELECT id FROM roles WHERE name = 'SuperAdmin'")
|
| 127 |
+
if not roles_result:
|
| 128 |
+
sa_role_id = _uuid()
|
| 129 |
+
admin_role_id = _uuid()
|
| 130 |
+
user_role_id = _uuid()
|
| 131 |
+
perm_ids = {}
|
| 132 |
+
for code, desc in [
|
| 133 |
+
("users:read", "Read users"),
|
| 134 |
+
("users:write", "Modify users"),
|
| 135 |
+
("users:delete", "Delete users"),
|
| 136 |
+
("admin:access", "Access admin panel"),
|
| 137 |
+
]:
|
| 138 |
+
pid = _uuid()
|
| 139 |
+
await mgr.execute(
|
| 140 |
+
"INSERT INTO permissions (id, code, description) VALUES (%s, %s, %s)",
|
| 141 |
+
(pid, code, desc),
|
| 142 |
+
)
|
| 143 |
+
perm_ids[code] = pid
|
| 144 |
|
| 145 |
+
await mgr.execute(
|
| 146 |
+
"INSERT INTO roles (id, name, description) VALUES (%s, %s, %s)",
|
| 147 |
+
(sa_role_id, "SuperAdmin", "Full system access"),
|
| 148 |
+
)
|
| 149 |
+
await mgr.execute(
|
| 150 |
+
"INSERT INTO roles (id, name, description) VALUES (%s, %s, %s)",
|
| 151 |
+
(admin_role_id, "Admin", "Administrative access"),
|
| 152 |
+
)
|
| 153 |
+
await mgr.execute(
|
| 154 |
+
"INSERT INTO roles (id, name, description) VALUES (%s, %s, %s)",
|
| 155 |
+
(user_role_id, "User", "Standard user access"),
|
| 156 |
+
)
|
| 157 |
|
| 158 |
+
perms_list = list(perm_ids.values())
|
| 159 |
+
for pid in perms_list:
|
| 160 |
+
await mgr.execute(
|
| 161 |
+
"INSERT INTO role_permissions (role_id, permission_id) VALUES (%s, %s)",
|
| 162 |
+
(sa_role_id, pid),
|
| 163 |
+
)
|
| 164 |
+
for pid in perms_list[:-1]:
|
| 165 |
+
await mgr.execute(
|
| 166 |
+
"INSERT INTO role_permissions (role_id, permission_id) VALUES (%s, %s)",
|
| 167 |
+
(admin_role_id, pid),
|
| 168 |
+
)
|
| 169 |
+
await mgr.execute(
|
| 170 |
+
"INSERT INTO role_permissions (role_id, permission_id) VALUES (%s, %s)",
|
| 171 |
+
(user_role_id, perms_list[0]),
|
| 172 |
+
)
|
| 173 |
|
| 174 |
+
admin_email = "admin@example.com"
|
| 175 |
+
admin_result = await mgr.fetchone("SELECT id FROM users WHERE email = %s", (admin_email,))
|
| 176 |
+
if not admin_result:
|
| 177 |
+
admin_password = _settings.admin_password or "Admin123!"
|
| 178 |
+
admin_id = _uuid()
|
| 179 |
+
now = _now()
|
| 180 |
+
await mgr.execute(
|
| 181 |
+
"INSERT INTO users (id, email, full_name, password_hash, is_verified, created_at, updated_at) "
|
| 182 |
+
"VALUES (%s, %s, %s, %s, 1, %s, %s)",
|
| 183 |
+
(admin_id, admin_email, "System Administrator", ph.hash(admin_password), now, now),
|
| 184 |
+
)
|
| 185 |
+
role_result = await mgr.fetchone("SELECT id FROM roles WHERE name = 'SuperAdmin'")
|
| 186 |
+
if role_result:
|
| 187 |
+
await mgr.execute(
|
| 188 |
+
"INSERT INTO user_roles (user_id, role_id) VALUES (%s, %s)",
|
| 189 |
+
(admin_id, role_result["id"]),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
+
from app.services.auth_service import AuthService
|
| 193 |
+
await AuthService.cleanup_expired_sessions(mgr)
|
| 194 |
|
| 195 |
|
| 196 |
+
async def get_db() -> TiDBManager:
|
| 197 |
+
mgr = get_tidb_manager()
|
| 198 |
+
if mgr is None:
|
| 199 |
+
raise HTTPException(
|
| 200 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 201 |
+
detail="Database not initialized. Configure TIDB_INSTANCES.",
|
| 202 |
+
)
|
| 203 |
+
return mgr
|
| 204 |
|
| 205 |
|
| 206 |
async def get_current_user(
|
| 207 |
request: Request,
|
| 208 |
+
db: Annotated[TiDBManager, Depends(get_db)],
|
| 209 |
) -> User:
|
| 210 |
credentials_exception = HTTPException(
|
| 211 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
| 228 |
except jwt.PyJWTError:
|
| 229 |
raise credentials_exception
|
| 230 |
|
| 231 |
+
row = await db.fetchone_all(
|
| 232 |
+
"SELECT * FROM users WHERE id = %s AND deleted_at IS NULL",
|
| 233 |
+
(user_id,),
|
| 234 |
+
)
|
| 235 |
+
if row is None:
|
| 236 |
+
raise credentials_exception
|
| 237 |
+
user = _row_to_user(row)
|
| 238 |
+
if not user.is_active:
|
| 239 |
raise credentials_exception
|
| 240 |
return user
|
| 241 |
|
|
|
|
| 263 |
user: Annotated[User, Depends(get_current_user)],
|
| 264 |
) -> User:
|
| 265 |
user_perms = set()
|
| 266 |
+
mgr = get_tidb_manager()
|
| 267 |
+
if mgr:
|
| 268 |
+
rows = await mgr.fetchall_all(
|
| 269 |
+
"SELECT p.code FROM permissions p "
|
| 270 |
+
"JOIN role_permissions rp ON rp.permission_id = p.id "
|
| 271 |
+
"JOIN user_roles ur ON ur.role_id = rp.role_id "
|
| 272 |
+
"WHERE ur.user_id = %s",
|
| 273 |
+
(user.id,),
|
| 274 |
+
)
|
| 275 |
+
user_perms = {r["code"] for r in rows}
|
| 276 |
missing = [p for p in required_perms if p not in user_perms]
|
| 277 |
if missing:
|
| 278 |
raise HTTPException(
|
|
|
|
| 282 |
return user
|
| 283 |
|
| 284 |
return permission_checker
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _row_to_user(row: dict) -> User:
|
| 288 |
+
return User(
|
| 289 |
+
id=row["id"],
|
| 290 |
+
email=row["email"],
|
| 291 |
+
username=row.get("username"),
|
| 292 |
+
full_name=row.get("full_name"),
|
| 293 |
+
password_hash=row["password_hash"],
|
| 294 |
+
is_active=bool(row["is_active"]),
|
| 295 |
+
is_verified=bool(row["is_verified"]),
|
| 296 |
+
failed_login_attempts=row["failed_login_attempts"],
|
| 297 |
+
locked_until=_deserialize_dt(row.get("locked_until")),
|
| 298 |
+
last_login=_deserialize_dt(row.get("last_login")),
|
| 299 |
+
password_changed_at=_deserialize_dt(row.get("password_changed_at")),
|
| 300 |
+
created_at=_deserialize_dt(row["created_at"]),
|
| 301 |
+
updated_at=_deserialize_dt(row["updated_at"]),
|
| 302 |
+
deleted_at=_deserialize_dt(row.get("deleted_at")),
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def _row_to_refresh_session(row: dict) -> RefreshSession:
|
| 307 |
+
return RefreshSession(
|
| 308 |
+
id=row["id"],
|
| 309 |
+
user_id=row["user_id"],
|
| 310 |
+
token_key=row["token_key"],
|
| 311 |
+
token_hash=row["token_hash"],
|
| 312 |
+
device_info=row.get("device_info"),
|
| 313 |
+
ip_address=row.get("ip_address"),
|
| 314 |
+
expires_at=_deserialize_dt(row["expires_at"]),
|
| 315 |
+
revoked_at=_deserialize_dt(row.get("revoked_at")),
|
| 316 |
+
created_at=_deserialize_dt(row["created_at"]),
|
| 317 |
+
updated_at=_deserialize_dt(row["updated_at"]),
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def _serialize_dt(dt: Optional[datetime]) -> Optional[str]:
|
| 322 |
+
if dt is None:
|
| 323 |
+
return None
|
| 324 |
+
return dt.strftime("%Y-%m-%d %H:%M:%S.%f")
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def _deserialize_dt(val) -> Optional[datetime]:
|
| 328 |
+
if val is None:
|
| 329 |
+
return None
|
| 330 |
+
if isinstance(val, datetime):
|
| 331 |
+
return val
|
| 332 |
+
if isinstance(val, str):
|
| 333 |
+
return datetime.fromisoformat(val)
|
| 334 |
+
return val
|
app/core/auth/models.py
CHANGED
|
@@ -1,16 +1,9 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import uuid
|
|
|
|
| 4 |
from datetime import datetime, timezone
|
| 5 |
-
|
| 6 |
-
from sqlalchemy import (
|
| 7 |
-
Boolean, Column, DateTime, ForeignKey, Integer, String, Text, Table,
|
| 8 |
-
)
|
| 9 |
-
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
class Base(DeclarativeBase):
|
| 13 |
-
pass
|
| 14 |
|
| 15 |
|
| 16 |
def _utcnow() -> datetime:
|
|
@@ -21,80 +14,49 @@ def _uuid() -> str:
|
|
| 21 |
return str(uuid.uuid4())
|
| 22 |
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
| 71 |
-
|
| 72 |
-
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
| 73 |
-
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 74 |
-
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 75 |
-
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 76 |
-
|
| 77 |
-
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
| 78 |
-
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False)
|
| 79 |
-
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 80 |
-
|
| 81 |
-
roles: Mapped[list[Role]] = relationship(secondary=user_roles, back_populates="users", lazy="selectin")
|
| 82 |
-
sessions: Mapped[list[RefreshSession]] = relationship(back_populates="user", cascade="all, delete-orphan", lazy="selectin")
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
class RefreshSession(Base):
|
| 86 |
-
__tablename__ = "refresh_sessions"
|
| 87 |
-
|
| 88 |
-
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
| 89 |
-
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
| 90 |
-
token_key: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
| 91 |
-
token_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 92 |
-
device_info: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 93 |
-
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
| 94 |
-
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
| 95 |
-
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 96 |
-
|
| 97 |
-
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
| 98 |
-
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False)
|
| 99 |
-
|
| 100 |
-
user: Mapped[User] = relationship(back_populates="sessions")
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import uuid
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
from datetime import datetime, timezone
|
| 6 |
+
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
def _utcnow() -> datetime:
|
|
|
|
| 14 |
return str(uuid.uuid4())
|
| 15 |
|
| 16 |
|
| 17 |
+
@dataclass
|
| 18 |
+
class User:
|
| 19 |
+
id: str = field(default_factory=_uuid)
|
| 20 |
+
email: str = ""
|
| 21 |
+
username: Optional[str] = None
|
| 22 |
+
full_name: Optional[str] = None
|
| 23 |
+
password_hash: str = ""
|
| 24 |
+
is_active: bool = True
|
| 25 |
+
is_verified: bool = False
|
| 26 |
+
failed_login_attempts: int = 0
|
| 27 |
+
locked_until: Optional[datetime] = None
|
| 28 |
+
last_login: Optional[datetime] = None
|
| 29 |
+
password_changed_at: Optional[datetime] = None
|
| 30 |
+
created_at: datetime = field(default_factory=_utcnow)
|
| 31 |
+
updated_at: datetime = field(default_factory=_utcnow)
|
| 32 |
+
deleted_at: Optional[datetime] = None
|
| 33 |
+
roles: list[str] = field(default_factory=list)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class RefreshSession:
|
| 38 |
+
id: str = field(default_factory=_uuid)
|
| 39 |
+
user_id: str = ""
|
| 40 |
+
token_key: str = ""
|
| 41 |
+
token_hash: str = ""
|
| 42 |
+
device_info: Optional[str] = None
|
| 43 |
+
ip_address: Optional[str] = None
|
| 44 |
+
expires_at: Optional[datetime] = None
|
| 45 |
+
revoked_at: Optional[datetime] = None
|
| 46 |
+
created_at: datetime = field(default_factory=_utcnow)
|
| 47 |
+
updated_at: datetime = field(default_factory=_utcnow)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class Role:
|
| 52 |
+
id: str = field(default_factory=_uuid)
|
| 53 |
+
name: str = ""
|
| 54 |
+
description: Optional[str] = None
|
| 55 |
+
permissions: list[str] = field(default_factory=list)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass
|
| 59 |
+
class Permission:
|
| 60 |
+
id: str = field(default_factory=_uuid)
|
| 61 |
+
code: str = ""
|
| 62 |
+
description: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/core/auth/schemas.py
CHANGED
|
@@ -117,8 +117,6 @@ class UserSchemaResponse(BaseModel):
|
|
| 117 |
columns: list[UserSchemaField]
|
| 118 |
|
| 119 |
|
| 120 |
-
# --- Wrapped API response schemas ---
|
| 121 |
-
|
| 122 |
class ApiResponse(BaseModel):
|
| 123 |
success: bool = True
|
| 124 |
|
|
@@ -126,34 +124,28 @@ class ApiResponse(BaseModel):
|
|
| 126 |
class DataResponse(ApiResponse):
|
| 127 |
data: dict
|
| 128 |
application_id: Optional[str] = None
|
| 129 |
-
warning: Optional[dict] = None
|
| 130 |
|
| 131 |
|
| 132 |
class TokenResponse(ApiResponse):
|
| 133 |
data: TokenData
|
| 134 |
application_id: Optional[str] = None
|
| 135 |
-
warning: Optional[dict] = None
|
| 136 |
|
| 137 |
|
| 138 |
class ProfileResponse(ApiResponse):
|
| 139 |
data: UserProfile
|
| 140 |
application_id: Optional[str] = None
|
| 141 |
-
warning: Optional[dict] = None
|
| 142 |
|
| 143 |
|
| 144 |
class MessageDataResponse(ApiResponse):
|
| 145 |
data: MessageResponse
|
| 146 |
application_id: Optional[str] = None
|
| 147 |
-
warning: Optional[dict] = None
|
| 148 |
|
| 149 |
|
| 150 |
class SchemaResponse(ApiResponse):
|
| 151 |
data: UserSchemaResponse
|
| 152 |
application_id: Optional[str] = None
|
| 153 |
-
warning: Optional[dict] = None
|
| 154 |
|
| 155 |
|
| 156 |
class SessionListResponse(ApiResponse):
|
| 157 |
data: list[SessionOut]
|
| 158 |
application_id: Optional[str] = None
|
| 159 |
-
warning: Optional[dict] = None
|
|
|
|
| 117 |
columns: list[UserSchemaField]
|
| 118 |
|
| 119 |
|
|
|
|
|
|
|
| 120 |
class ApiResponse(BaseModel):
|
| 121 |
success: bool = True
|
| 122 |
|
|
|
|
| 124 |
class DataResponse(ApiResponse):
|
| 125 |
data: dict
|
| 126 |
application_id: Optional[str] = None
|
|
|
|
| 127 |
|
| 128 |
|
| 129 |
class TokenResponse(ApiResponse):
|
| 130 |
data: TokenData
|
| 131 |
application_id: Optional[str] = None
|
|
|
|
| 132 |
|
| 133 |
|
| 134 |
class ProfileResponse(ApiResponse):
|
| 135 |
data: UserProfile
|
| 136 |
application_id: Optional[str] = None
|
|
|
|
| 137 |
|
| 138 |
|
| 139 |
class MessageDataResponse(ApiResponse):
|
| 140 |
data: MessageResponse
|
| 141 |
application_id: Optional[str] = None
|
|
|
|
| 142 |
|
| 143 |
|
| 144 |
class SchemaResponse(ApiResponse):
|
| 145 |
data: UserSchemaResponse
|
| 146 |
application_id: Optional[str] = None
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
class SessionListResponse(ApiResponse):
|
| 150 |
data: list[SessionOut]
|
| 151 |
application_id: Optional[str] = None
|
|
|
app/core/database/__init__.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
from app.core.database.base import BaseExecutor, ConnectionConfig, StatementResult
|
| 2 |
-
from app.core.database.mysql import MySQLExecutor
|
| 3 |
-
from app.core.database.postgresql import PostgreSQLExecutor
|
| 4 |
from app.core.database.mongodb import MongoDBExecutor
|
|
|
|
| 5 |
from app.core.database.pool import pool_manager
|
|
|
|
| 6 |
|
| 7 |
__all__ = [
|
| 8 |
"BaseExecutor",
|
|
|
|
| 1 |
from app.core.database.base import BaseExecutor, ConnectionConfig, StatementResult
|
|
|
|
|
|
|
| 2 |
from app.core.database.mongodb import MongoDBExecutor
|
| 3 |
+
from app.core.database.mysql import MySQLExecutor
|
| 4 |
from app.core.database.pool import pool_manager
|
| 5 |
+
from app.core.database.postgresql import PostgreSQLExecutor
|
| 6 |
|
| 7 |
__all__ = [
|
| 8 |
"BaseExecutor",
|
app/core/database/pool.py
CHANGED
|
@@ -3,9 +3,9 @@ from __future__ import annotations
|
|
| 3 |
import asyncio
|
| 4 |
|
| 5 |
from app.core.database.base import BaseExecutor, ConnectionConfig
|
|
|
|
| 6 |
from app.core.database.mysql import MySQLExecutor
|
| 7 |
from app.core.database.postgresql import PostgreSQLExecutor
|
| 8 |
-
from app.core.database.mongodb import MongoDBExecutor
|
| 9 |
from app.core.logger import get_logger
|
| 10 |
|
| 11 |
_logger = get_logger(__name__)
|
|
|
|
| 3 |
import asyncio
|
| 4 |
|
| 5 |
from app.core.database.base import BaseExecutor, ConnectionConfig
|
| 6 |
+
from app.core.database.mongodb import MongoDBExecutor
|
| 7 |
from app.core.database.mysql import MySQLExecutor
|
| 8 |
from app.core.database.postgresql import PostgreSQLExecutor
|
|
|
|
| 9 |
from app.core.logger import get_logger
|
| 10 |
|
| 11 |
_logger = get_logger(__name__)
|
app/core/redis_client.py
CHANGED
|
@@ -5,7 +5,6 @@ from typing import Optional
|
|
| 5 |
|
| 6 |
from redis.asyncio import Redis
|
| 7 |
|
| 8 |
-
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
|
| 11 |
|
|
|
|
| 5 |
|
| 6 |
from redis.asyncio import Redis
|
| 7 |
|
|
|
|
| 8 |
logger = logging.getLogger(__name__)
|
| 9 |
|
| 10 |
|
app/core/tidb_manager.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
import ssl
|
| 6 |
+
from typing import Any, Dict, List, Optional, Set
|
| 7 |
+
from urllib.parse import urlparse
|
| 8 |
+
|
| 9 |
+
import aiomysql
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
# MySQL/TiDB error codes that indicate the instance cannot accept more writes:
|
| 14 |
+
# 1114 (ER_RECORD_FILE_FULL) – "The table '...' is full"
|
| 15 |
+
# 1021 (ER_DISK_FULL) – "Disk full (...)"
|
| 16 |
+
# 126 (ER_INDEX_FILE_FULL) – "Index file is full"
|
| 17 |
+
# 1877 (ER_TABLE_FULL) – "Table full"
|
| 18 |
+
_STORAGE_FULL_CODES: Set[int] = {1114, 1021, 126, 1877}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TiDBWriteError(RuntimeError):
|
| 22 |
+
"""All TiDB instances are full or unavailable — writes cannot be accepted."""
|
| 23 |
+
def __init__(self):
|
| 24 |
+
super().__init__("No TiDB instances available for write. All instances are full or unreachable.")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _is_storage_full_error(exc: Exception) -> bool:
|
| 28 |
+
if isinstance(exc, aiomysql.MySQLError):
|
| 29 |
+
code = exc.args[0] if exc.args else None
|
| 30 |
+
if isinstance(code, int) and code in _STORAGE_FULL_CODES:
|
| 31 |
+
return True
|
| 32 |
+
msg = str(exc).lower()
|
| 33 |
+
for keyword in ("table is full", "disk full", "storage full",
|
| 34 |
+
"quota exceeded", "out of storage"):
|
| 35 |
+
if keyword in msg:
|
| 36 |
+
return True
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class TiDBInstance:
|
| 41 |
+
def __init__(self, host: str, port: int, user: str, password: str, database: str,
|
| 42 |
+
ssl: Optional[ssl.SSLContext] = None):
|
| 43 |
+
self.host = host
|
| 44 |
+
self.port = port
|
| 45 |
+
self.user = user
|
| 46 |
+
self.password = password
|
| 47 |
+
self.database = database
|
| 48 |
+
self.ssl = ssl
|
| 49 |
+
self.pool: Optional[aiomysql.Pool] = None
|
| 50 |
+
self.is_full = False
|
| 51 |
+
self.is_available = True
|
| 52 |
+
|
| 53 |
+
@classmethod
|
| 54 |
+
def from_url(cls, url: str,
|
| 55 |
+
ssl: Optional[ssl.SSLContext] = None) -> TiDBInstance:
|
| 56 |
+
parsed = urlparse(url)
|
| 57 |
+
host = parsed.hostname or "localhost"
|
| 58 |
+
port = parsed.port or 4000
|
| 59 |
+
user = parsed.username or "root"
|
| 60 |
+
password = parsed.password or ""
|
| 61 |
+
database = parsed.path.lstrip("/") or "test"
|
| 62 |
+
return cls(host=host, port=port, user=user, password=password,
|
| 63 |
+
database=database, ssl=ssl)
|
| 64 |
+
|
| 65 |
+
async def create_pool(self, minsize: int = 1, maxsize: int = 10):
|
| 66 |
+
self.pool = await aiomysql.create_pool(
|
| 67 |
+
host=self.host,
|
| 68 |
+
port=self.port,
|
| 69 |
+
user=self.user,
|
| 70 |
+
password=self.password,
|
| 71 |
+
db=self.database,
|
| 72 |
+
minsize=minsize,
|
| 73 |
+
maxsize=maxsize,
|
| 74 |
+
autocommit=False,
|
| 75 |
+
cursorclass=aiomysql.DictCursor,
|
| 76 |
+
ssl=self.ssl,
|
| 77 |
+
)
|
| 78 |
+
self.is_available = True
|
| 79 |
+
return self.pool
|
| 80 |
+
|
| 81 |
+
async def close_pool(self):
|
| 82 |
+
if self.pool:
|
| 83 |
+
await self.pool.close()
|
| 84 |
+
await self.pool.wait_closed()
|
| 85 |
+
self.pool = None
|
| 86 |
+
|
| 87 |
+
def __repr__(self) -> str:
|
| 88 |
+
return f"TiDBInstance({self.user}@{self.host}:{self.port}/{self.database})"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class TiDBManager:
|
| 92 |
+
def __init__(self, instance_urls: List[str],
|
| 93 |
+
ssl: Optional[ssl.SSLContext] = None):
|
| 94 |
+
self.instances = [TiDBInstance.from_url(url, ssl=ssl) for url in instance_urls]
|
| 95 |
+
self._current_instance_index = 0
|
| 96 |
+
|
| 97 |
+
@property
|
| 98 |
+
def current_instance(self) -> TiDBInstance:
|
| 99 |
+
return self.instances[self._current_instance_index]
|
| 100 |
+
|
| 101 |
+
@property
|
| 102 |
+
def current_index(self) -> int:
|
| 103 |
+
return self._current_instance_index
|
| 104 |
+
|
| 105 |
+
async def initialize_pools(self, minsize: int = 1, maxsize: int = 10):
|
| 106 |
+
for inst in self.instances:
|
| 107 |
+
try:
|
| 108 |
+
await inst.create_pool(minsize=minsize, maxsize=maxsize)
|
| 109 |
+
logger.info("Connected to TiDB: %s", inst)
|
| 110 |
+
except Exception as exc:
|
| 111 |
+
logger.error("Failed to connect to TiDB %s: %s", inst, exc)
|
| 112 |
+
inst.is_available = False
|
| 113 |
+
|
| 114 |
+
async def close_all_pools(self):
|
| 115 |
+
for inst in self.instances:
|
| 116 |
+
await inst.close_pool()
|
| 117 |
+
|
| 118 |
+
async def ensure_tables(self, table_ddls: Dict[str, str]):
|
| 119 |
+
for inst in self.instances:
|
| 120 |
+
if not inst.is_available or not inst.pool:
|
| 121 |
+
continue
|
| 122 |
+
async with inst.pool.acquire() as conn:
|
| 123 |
+
async with conn.cursor() as cur:
|
| 124 |
+
for table_name, ddl in table_ddls.items():
|
| 125 |
+
try:
|
| 126 |
+
await cur.execute(
|
| 127 |
+
"SELECT 1 FROM information_schema.tables "
|
| 128 |
+
"WHERE table_schema = %s AND table_name = %s",
|
| 129 |
+
(inst.database, table_name),
|
| 130 |
+
)
|
| 131 |
+
exists = await cur.fetchone()
|
| 132 |
+
if not exists:
|
| 133 |
+
for statement in ddl.split(";"):
|
| 134 |
+
stmt = statement.strip()
|
| 135 |
+
if stmt:
|
| 136 |
+
await cur.execute(stmt)
|
| 137 |
+
logger.info("Created table '%s' on %s", table_name, inst)
|
| 138 |
+
except Exception as exc:
|
| 139 |
+
logger.error(
|
| 140 |
+
"Failed to create table '%s' on %s: %s",
|
| 141 |
+
table_name, inst, exc,
|
| 142 |
+
)
|
| 143 |
+
await conn.commit()
|
| 144 |
+
|
| 145 |
+
async def has_table(self, table_name: str) -> bool:
|
| 146 |
+
inst = self.current_instance
|
| 147 |
+
if not inst.pool:
|
| 148 |
+
return False
|
| 149 |
+
async with inst.pool.acquire() as conn:
|
| 150 |
+
async with conn.cursor() as cur:
|
| 151 |
+
await cur.execute(
|
| 152 |
+
"SELECT 1 FROM information_schema.tables "
|
| 153 |
+
"WHERE table_schema = %s AND table_name = %s",
|
| 154 |
+
(inst.database, table_name),
|
| 155 |
+
)
|
| 156 |
+
return await cur.fetchone() is not None
|
| 157 |
+
|
| 158 |
+
async def check_and_rotate(self) -> bool:
|
| 159 |
+
if self.current_instance.is_available and not self.current_instance.is_full:
|
| 160 |
+
return True
|
| 161 |
+
|
| 162 |
+
for i in range(1, len(self.instances)):
|
| 163 |
+
idx = (self._current_instance_index + i) % len(self.instances)
|
| 164 |
+
inst = self.instances[idx]
|
| 165 |
+
if inst.is_available and not inst.is_full:
|
| 166 |
+
logger.info(
|
| 167 |
+
"Rotating TiDB write target: %s -> %s",
|
| 168 |
+
self.instances[self._current_instance_index], inst,
|
| 169 |
+
)
|
| 170 |
+
self._current_instance_index = idx
|
| 171 |
+
return True
|
| 172 |
+
|
| 173 |
+
logger.error("All TiDB instances are full or unavailable")
|
| 174 |
+
return False
|
| 175 |
+
|
| 176 |
+
async def fetchone(self, query: str, params=None) -> Optional[Dict[str, Any]]:
|
| 177 |
+
inst = self.current_instance
|
| 178 |
+
if not inst.pool:
|
| 179 |
+
return None
|
| 180 |
+
async with inst.pool.acquire() as conn:
|
| 181 |
+
async with conn.cursor() as cur:
|
| 182 |
+
await cur.execute(query, params or ())
|
| 183 |
+
return await cur.fetchone()
|
| 184 |
+
|
| 185 |
+
async def fetchone_all(self, query: str, params=None) -> Optional[Dict[str, Any]]:
|
| 186 |
+
async def _query(inst_: TiDBInstance):
|
| 187 |
+
try:
|
| 188 |
+
async with inst_.pool.acquire() as conn:
|
| 189 |
+
async with conn.cursor() as cur:
|
| 190 |
+
await cur.execute(query, params or ())
|
| 191 |
+
return await cur.fetchone()
|
| 192 |
+
except asyncio.CancelledError:
|
| 193 |
+
raise
|
| 194 |
+
except Exception as exc:
|
| 195 |
+
logger.error("Error reading from %s: %s", inst_, exc)
|
| 196 |
+
return None
|
| 197 |
+
|
| 198 |
+
tasks = [
|
| 199 |
+
asyncio.create_task(_query(inst))
|
| 200 |
+
for inst in self.instances
|
| 201 |
+
if inst.pool and inst.is_available
|
| 202 |
+
]
|
| 203 |
+
if not tasks:
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
for coro in asyncio.as_completed(tasks):
|
| 207 |
+
result = await coro
|
| 208 |
+
if result is not None:
|
| 209 |
+
for t in tasks:
|
| 210 |
+
if not t.done():
|
| 211 |
+
t.cancel()
|
| 212 |
+
return result
|
| 213 |
+
return None
|
| 214 |
+
|
| 215 |
+
async def fetchall(self, query: str, params=None) -> List[Dict[str, Any]]:
|
| 216 |
+
inst = self.current_instance
|
| 217 |
+
if not inst.pool:
|
| 218 |
+
return []
|
| 219 |
+
async with inst.pool.acquire() as conn:
|
| 220 |
+
async with conn.cursor() as cur:
|
| 221 |
+
await cur.execute(query, params or ())
|
| 222 |
+
return await cur.fetchall()
|
| 223 |
+
|
| 224 |
+
async def fetchall_all(self, query: str, params=None) -> List[Dict[str, Any]]:
|
| 225 |
+
async def _query(inst_: TiDBInstance):
|
| 226 |
+
try:
|
| 227 |
+
async with inst_.pool.acquire() as conn:
|
| 228 |
+
async with conn.cursor() as cur:
|
| 229 |
+
await cur.execute(query, params or ())
|
| 230 |
+
return await cur.fetchall()
|
| 231 |
+
except asyncio.CancelledError:
|
| 232 |
+
raise
|
| 233 |
+
except Exception as exc:
|
| 234 |
+
logger.error("Error reading from %s: %s", inst_, exc)
|
| 235 |
+
return []
|
| 236 |
+
|
| 237 |
+
tasks = [
|
| 238 |
+
asyncio.create_task(_query(inst))
|
| 239 |
+
for inst in self.instances
|
| 240 |
+
if inst.pool and inst.is_available
|
| 241 |
+
]
|
| 242 |
+
if not tasks:
|
| 243 |
+
return []
|
| 244 |
+
|
| 245 |
+
all_rows = await asyncio.gather(*tasks)
|
| 246 |
+
seen = set()
|
| 247 |
+
merged = []
|
| 248 |
+
for rows in all_rows:
|
| 249 |
+
for row in rows:
|
| 250 |
+
key = tuple(row.items())
|
| 251 |
+
if key not in seen:
|
| 252 |
+
seen.add(key)
|
| 253 |
+
merged.append(row)
|
| 254 |
+
return merged
|
| 255 |
+
|
| 256 |
+
async def _try_write(self, query: str, params) -> int:
|
| 257 |
+
inst = self.current_instance
|
| 258 |
+
if not inst.pool:
|
| 259 |
+
raise RuntimeError(f"TiDB pool not initialized for {inst}")
|
| 260 |
+
async with inst.pool.acquire() as conn:
|
| 261 |
+
async with conn.cursor(aiomysql.DictCursor) as cur:
|
| 262 |
+
await cur.execute(query, params or ())
|
| 263 |
+
await conn.commit()
|
| 264 |
+
return cur.lastrowid if cur.lastrowid is not None else 0
|
| 265 |
+
|
| 266 |
+
async def execute(self, query: str, params=None) -> int:
|
| 267 |
+
ok = await self.check_and_rotate()
|
| 268 |
+
if not ok:
|
| 269 |
+
raise TiDBWriteError()
|
| 270 |
+
try:
|
| 271 |
+
return await self._try_write(query, params)
|
| 272 |
+
except Exception as exc:
|
| 273 |
+
if _is_storage_full_error(exc):
|
| 274 |
+
logger.warning("TiDB instance %s is full, rotating...", self.current_instance)
|
| 275 |
+
self.current_instance.is_full = True
|
| 276 |
+
ok = await self.check_and_rotate()
|
| 277 |
+
if not ok:
|
| 278 |
+
raise TiDBWriteError() from exc
|
| 279 |
+
try:
|
| 280 |
+
return await self._try_write(query, params)
|
| 281 |
+
except Exception:
|
| 282 |
+
raise TiDBWriteError() from exc
|
| 283 |
+
raise
|
| 284 |
+
|
| 285 |
+
async def execute_on_all(self, query: str, params=None):
|
| 286 |
+
for inst in self.instances:
|
| 287 |
+
if not inst.is_available or not inst.pool:
|
| 288 |
+
continue
|
| 289 |
+
try:
|
| 290 |
+
async with inst.pool.acquire() as conn:
|
| 291 |
+
async with conn.cursor(aiomysql.DictCursor) as cur:
|
| 292 |
+
await cur.execute(query, params or ())
|
| 293 |
+
await conn.commit()
|
| 294 |
+
except Exception as exc:
|
| 295 |
+
logger.error("Error executing on %s: %s", inst, exc)
|
| 296 |
+
|
| 297 |
+
async def insert_and_get_id(self, query: str, params=None) -> int:
|
| 298 |
+
ok = await self.check_and_rotate()
|
| 299 |
+
if not ok:
|
| 300 |
+
raise TiDBWriteError()
|
| 301 |
+
try:
|
| 302 |
+
return await self._try_write(query, params)
|
| 303 |
+
except Exception as exc:
|
| 304 |
+
if _is_storage_full_error(exc):
|
| 305 |
+
logger.warning("TiDB instance %s is full, rotating...", self.current_instance)
|
| 306 |
+
self.current_instance.is_full = True
|
| 307 |
+
ok = await self.check_and_rotate()
|
| 308 |
+
if not ok:
|
| 309 |
+
raise TiDBWriteError() from exc
|
| 310 |
+
try:
|
| 311 |
+
return await self._try_write(query, params)
|
| 312 |
+
except Exception:
|
| 313 |
+
raise TiDBWriteError() from exc
|
| 314 |
+
raise
|
| 315 |
+
|
| 316 |
+
def get_instances_summary(self) -> List[Dict[str, Any]]:
|
| 317 |
+
return [
|
| 318 |
+
{
|
| 319 |
+
"host": inst.host,
|
| 320 |
+
"port": inst.port,
|
| 321 |
+
"database": inst.database,
|
| 322 |
+
"is_available": inst.is_available,
|
| 323 |
+
"is_full": inst.is_full,
|
| 324 |
+
"is_current": i == self._current_instance_index,
|
| 325 |
+
}
|
| 326 |
+
for i, inst in enumerate(self.instances)
|
| 327 |
+
]
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
_tidb_manager: Optional[TiDBManager] = None
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def get_tidb_manager() -> Optional[TiDBManager]:
|
| 334 |
+
global _tidb_manager
|
| 335 |
+
return _tidb_manager
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def set_tidb_manager(mgr: TiDBManager):
|
| 339 |
+
global _tidb_manager
|
| 340 |
+
_tidb_manager = mgr
|
app/core/vector_store/__init__.py
CHANGED
|
@@ -1,18 +1,17 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from app.core.vector_store.models import VectorStoreIndex, Base
|
| 4 |
from app.core.vector_store.deps import (
|
| 5 |
-
engine,
|
| 6 |
-
AsyncSessionLocal,
|
| 7 |
-
init_vector_store_db,
|
| 8 |
get_vs_db,
|
|
|
|
|
|
|
|
|
|
| 9 |
)
|
|
|
|
| 10 |
|
| 11 |
__all__ = [
|
| 12 |
-
"Base",
|
| 13 |
"VectorStoreIndex",
|
| 14 |
-
"engine",
|
| 15 |
-
"AsyncSessionLocal",
|
| 16 |
"init_vector_store_db",
|
| 17 |
"get_vs_db",
|
|
|
|
|
|
|
| 18 |
]
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from app.core.vector_store.deps import (
|
|
|
|
|
|
|
|
|
|
| 4 |
get_vs_db,
|
| 5 |
+
get_vs_manager,
|
| 6 |
+
init_vector_store_db,
|
| 7 |
+
set_vs_manager,
|
| 8 |
)
|
| 9 |
+
from app.core.vector_store.models import VectorStoreIndex
|
| 10 |
|
| 11 |
__all__ = [
|
|
|
|
| 12 |
"VectorStoreIndex",
|
|
|
|
|
|
|
| 13 |
"init_vector_store_db",
|
| 14 |
"get_vs_db",
|
| 15 |
+
"set_vs_manager",
|
| 16 |
+
"get_vs_manager",
|
| 17 |
]
|
app/core/vector_store/deps.py
CHANGED
|
@@ -1,32 +1,52 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import
|
| 4 |
-
from typing import
|
| 5 |
-
|
| 6 |
-
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
| 7 |
|
| 8 |
from app.config import get_settings
|
| 9 |
-
from app.core.
|
|
|
|
|
|
|
| 10 |
|
| 11 |
_settings = get_settings()
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
VECTOR_STORE_DB_URL = f"sqlite+aiosqlite:///{os.path.join(DATA_DIR, 'vector_stores.db')}"
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
-
async def
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
try:
|
| 30 |
-
yield session
|
| 31 |
-
finally:
|
| 32 |
-
await session.close()
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Optional
|
|
|
|
|
|
|
| 5 |
|
| 6 |
from app.config import get_settings
|
| 7 |
+
from app.core.tidb_manager import TiDBManager, get_tidb_manager
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
|
| 11 |
_settings = get_settings()
|
| 12 |
|
| 13 |
+
VS_TABLES: dict[str, str] = {
|
| 14 |
+
"vector_store_index": """
|
| 15 |
+
CREATE TABLE IF NOT EXISTS vector_store_index (
|
| 16 |
+
store_id VARCHAR(36) PRIMARY KEY,
|
| 17 |
+
name VARCHAR(255) NOT NULL,
|
| 18 |
+
path VARCHAR(1024) NOT NULL,
|
| 19 |
+
description VARCHAR(1024) DEFAULT '',
|
| 20 |
+
metadata_json TEXT DEFAULT '{}',
|
| 21 |
+
created_at VARCHAR(64) NOT NULL
|
| 22 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 23 |
+
""",
|
| 24 |
+
}
|
| 25 |
|
|
|
|
| 26 |
|
| 27 |
+
async def init_vector_store_db():
|
| 28 |
+
mgr = get_tidb_manager()
|
| 29 |
+
if mgr is None:
|
| 30 |
+
logger.error("Cannot init vector store DB: TiDB manager not initialized")
|
| 31 |
+
return
|
| 32 |
+
await mgr.ensure_tables(VS_TABLES)
|
| 33 |
+
logger.info("Vector store tables ensured on all TiDB instances")
|
| 34 |
|
| 35 |
|
| 36 |
+
async def get_vs_db() -> TiDBManager:
|
| 37 |
+
mgr = get_tidb_manager()
|
| 38 |
+
if mgr is None:
|
| 39 |
+
raise RuntimeError("TiDB manager not initialized")
|
| 40 |
+
return mgr
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
_tidb_vs_manager: Optional[TiDBManager] = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def set_vs_manager(mgr: TiDBManager):
|
| 47 |
+
global _tidb_vs_manager
|
| 48 |
+
_tidb_vs_manager = mgr
|
| 49 |
|
| 50 |
|
| 51 |
+
def get_vs_manager() -> Optional[TiDBManager]:
|
| 52 |
+
return _tidb_vs_manager
|
|
|
|
|
|
|
|
|
|
|
|
app/core/vector_store/models.py
CHANGED
|
@@ -1,30 +1,23 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
|
|
|
| 4 |
from datetime import datetime, timezone
|
| 5 |
from typing import Any, Dict
|
| 6 |
|
| 7 |
-
from sqlalchemy import String, Text
|
| 8 |
-
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class Base(DeclarativeBase):
|
| 12 |
-
pass
|
| 13 |
-
|
| 14 |
|
| 15 |
def _utcnow() -> str:
|
| 16 |
return datetime.now(timezone.utc).isoformat()
|
| 17 |
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
created_at: Mapped[str] = mapped_column(String(64), default=_utcnow)
|
| 28 |
|
| 29 |
def to_dict(self) -> Dict[str, Any]:
|
| 30 |
return {
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
from datetime import datetime, timezone
|
| 6 |
from typing import Any, Dict
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
def _utcnow() -> str:
|
| 10 |
return datetime.now(timezone.utc).isoformat()
|
| 11 |
|
| 12 |
|
| 13 |
+
@dataclass
|
| 14 |
+
class VectorStoreIndex:
|
| 15 |
+
store_id: str = ""
|
| 16 |
+
name: str = ""
|
| 17 |
+
path: str = ""
|
| 18 |
+
description: str = ""
|
| 19 |
+
metadata_json: str = "{}"
|
| 20 |
+
created_at: str = field(default_factory=_utcnow)
|
|
|
|
| 21 |
|
| 22 |
def to_dict(self) -> Dict[str, Any]:
|
| 23 |
return {
|
app/models/domain.py
CHANGED
|
@@ -4,7 +4,6 @@ from dataclasses import dataclass, field
|
|
| 4 |
|
| 5 |
import tiktoken
|
| 6 |
|
| 7 |
-
|
| 8 |
_ENCODING_CACHE: dict[str, tiktoken.Encoding] = {}
|
| 9 |
|
| 10 |
|
|
|
|
| 4 |
|
| 5 |
import tiktoken
|
| 6 |
|
|
|
|
| 7 |
_ENCODING_CACHE: dict[str, tiktoken.Encoding] = {}
|
| 8 |
|
| 9 |
|
app/models/schemas.py
CHANGED
|
@@ -573,7 +573,6 @@ class VectorStoreResponse(BaseModel):
|
|
| 573 |
document_count: int
|
| 574 |
created_at: str
|
| 575 |
metadata: Dict[str, Any] = {}
|
| 576 |
-
warning: Optional[Dict[str, str]] = None
|
| 577 |
|
| 578 |
|
| 579 |
class VectorStoreListResponse(BaseModel):
|
|
|
|
| 573 |
document_count: int
|
| 574 |
created_at: str
|
| 575 |
metadata: Dict[str, Any] = {}
|
|
|
|
| 576 |
|
| 577 |
|
| 578 |
class VectorStoreListResponse(BaseModel):
|
app/services/__init__.py
CHANGED
|
@@ -2,21 +2,21 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from app.services.auth_service import AuthService
|
| 4 |
from app.services.converter_service import ConverterService
|
| 5 |
-
from app.services.embeddings_service import EmbeddingService
|
| 6 |
-
from app.services.extraction_service import ExtractionService
|
| 7 |
-
from app.services.ocr_service import OCRService
|
| 8 |
from app.services.dataset_metadata_service import (
|
| 9 |
ExtractionConfig,
|
| 10 |
-
extract_metadata,
|
| 11 |
-
FileType,
|
| 12 |
FileSource,
|
|
|
|
| 13 |
MetadataExtractionError,
|
|
|
|
| 14 |
)
|
|
|
|
|
|
|
|
|
|
| 15 |
from app.services.prompt_service import (
|
|
|
|
|
|
|
| 16 |
build_csv_context_prompt,
|
| 17 |
build_csv_system_prompt_with_context,
|
| 18 |
-
CSV_SYSTEM_PROMPT,
|
| 19 |
-
CSV_STRICT_OUTPUT_PROMPT,
|
| 20 |
)
|
| 21 |
|
| 22 |
__all__ = [
|
|
|
|
| 2 |
|
| 3 |
from app.services.auth_service import AuthService
|
| 4 |
from app.services.converter_service import ConverterService
|
|
|
|
|
|
|
|
|
|
| 5 |
from app.services.dataset_metadata_service import (
|
| 6 |
ExtractionConfig,
|
|
|
|
|
|
|
| 7 |
FileSource,
|
| 8 |
+
FileType,
|
| 9 |
MetadataExtractionError,
|
| 10 |
+
extract_metadata,
|
| 11 |
)
|
| 12 |
+
from app.services.embeddings_service import EmbeddingService
|
| 13 |
+
from app.services.extraction_service import ExtractionService
|
| 14 |
+
from app.services.ocr_service import OCRService
|
| 15 |
from app.services.prompt_service import (
|
| 16 |
+
CSV_STRICT_OUTPUT_PROMPT,
|
| 17 |
+
CSV_SYSTEM_PROMPT,
|
| 18 |
build_csv_context_prompt,
|
| 19 |
build_csv_system_prompt_with_context,
|
|
|
|
|
|
|
| 20 |
)
|
| 21 |
|
| 22 |
__all__ = [
|
app/services/auth_service.py
CHANGED
|
@@ -9,12 +9,11 @@ import jwt
|
|
| 9 |
from argon2 import PasswordHasher
|
| 10 |
from argon2.exceptions import VerifyMismatchError
|
| 11 |
from fastapi import HTTPException, Request
|
| 12 |
-
from sqlalchemy import select, update
|
| 13 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 14 |
|
| 15 |
from app.config import get_settings
|
|
|
|
|
|
|
| 16 |
from app.core.auth.models import _utcnow as _now
|
| 17 |
-
from app.core.auth.models import RefreshSession, Role, User
|
| 18 |
from app.core.auth.schemas import (
|
| 19 |
ChangePasswordSchema,
|
| 20 |
ForgotPasswordSchema,
|
|
@@ -25,6 +24,7 @@ from app.core.auth.schemas import (
|
|
| 25 |
UpdateProfileSchema,
|
| 26 |
UserProfile,
|
| 27 |
)
|
|
|
|
| 28 |
|
| 29 |
logger = logging.getLogger("auth_service")
|
| 30 |
_settings = get_settings()
|
|
@@ -38,43 +38,51 @@ def _token_key(raw: str) -> str:
|
|
| 38 |
class AuthService:
|
| 39 |
|
| 40 |
@staticmethod
|
| 41 |
-
async def register(db:
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
| 44 |
raise HTTPException(status_code=409, detail="Email already registered")
|
| 45 |
|
| 46 |
if schema.username:
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 49 |
raise HTTPException(status_code=409, detail="Username already taken")
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
full_name
|
| 55 |
-
|
|
|
|
| 56 |
)
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
db.
|
| 64 |
-
|
| 65 |
-
await db.refresh(user)
|
| 66 |
-
return user
|
| 67 |
|
| 68 |
@staticmethod
|
| 69 |
-
async def login(db:
|
| 70 |
-
|
| 71 |
-
|
|
|
|
| 72 |
)
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
if not user:
|
| 76 |
raise HTTPException(status_code=401, detail="Incorrect email or password")
|
| 77 |
|
|
|
|
|
|
|
| 78 |
if user.locked_until and user.locked_until > _now():
|
| 79 |
raise HTTPException(
|
| 80 |
status_code=403,
|
|
@@ -85,149 +93,158 @@ class AuthService:
|
|
| 85 |
user.failed_login_attempts += 1
|
| 86 |
if user.failed_login_attempts >= _settings.max_login_attempts:
|
| 87 |
user.locked_until = _now() + timedelta(minutes=_settings.lockout_minutes)
|
| 88 |
-
await db.
|
|
|
|
|
|
|
|
|
|
| 89 |
raise HTTPException(status_code=401, detail="Incorrect email or password")
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
access_token = AuthService._create_access_token(user.id)
|
| 96 |
raw_refresh, refresh_hash, token_key, expires_at = AuthService._create_refresh_token()
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
token_key
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
)
|
| 106 |
-
db.add(session)
|
| 107 |
-
await db.commit()
|
| 108 |
|
| 109 |
return TokenData(access_token=access_token, refresh_token=raw_refresh)
|
| 110 |
|
| 111 |
@staticmethod
|
| 112 |
-
async def refresh(db:
|
| 113 |
key = _token_key(raw_refresh_token)
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
RefreshSession.expires_at > _now(),
|
| 120 |
-
)
|
| 121 |
)
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
session = await db.execute(
|
| 126 |
-
select(RefreshSession).where(RefreshSession.token_key == key)
|
| 127 |
)
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
await db.
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
)
|
| 135 |
-
await db.commit()
|
| 136 |
raise HTTPException(
|
| 137 |
status_code=401,
|
| 138 |
detail="Session compromised. All sessions revoked. Please login again.",
|
| 139 |
)
|
| 140 |
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
| 141 |
|
|
|
|
|
|
|
| 142 |
if not AuthService._verify_token(raw_refresh_token, session.token_hash):
|
| 143 |
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
)
|
| 149 |
)
|
| 150 |
-
|
| 151 |
-
if not user:
|
| 152 |
raise HTTPException(status_code=401, detail="User not found or inactive")
|
|
|
|
| 153 |
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
new_access = AuthService._create_access_token(user.id)
|
| 157 |
new_raw_refresh, new_hash, new_key, new_expires = AuthService._create_refresh_token()
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
token_key
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
| 166 |
)
|
| 167 |
-
db.add(new_session)
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
| 171 |
|
| 172 |
return TokenData(access_token=new_access, refresh_token=new_raw_refresh)
|
| 173 |
|
| 174 |
@staticmethod
|
| 175 |
-
async def logout(db:
|
| 176 |
key = _token_key(raw_refresh_token)
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
RefreshSession.revoked_at.is_(None),
|
| 182 |
-
)
|
| 183 |
)
|
| 184 |
-
|
| 185 |
-
if not session:
|
| 186 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
| 189 |
|
| 190 |
@staticmethod
|
| 191 |
-
async def logout_all(db:
|
| 192 |
-
now = _now()
|
| 193 |
await db.execute(
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
RefreshSession.revoked_at.is_(None),
|
| 198 |
-
)
|
| 199 |
-
.values(revoked_at=now)
|
| 200 |
)
|
| 201 |
-
await db.commit()
|
| 202 |
|
| 203 |
@staticmethod
|
| 204 |
-
async def change_password(db:
|
| 205 |
if not AuthService._verify_password(schema.current_password, user.password_hash):
|
| 206 |
raise HTTPException(status_code=400, detail="Incorrect current password")
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
| 210 |
|
| 211 |
@staticmethod
|
| 212 |
-
async def forgot_password(db:
|
| 213 |
-
|
| 214 |
-
|
|
|
|
| 215 |
)
|
| 216 |
-
|
| 217 |
-
if user:
|
| 218 |
reset_token = jwt.encode(
|
| 219 |
{
|
| 220 |
-
"sub":
|
| 221 |
"type": "reset_password",
|
| 222 |
"exp": _now() + timedelta(hours=1),
|
| 223 |
},
|
| 224 |
_settings.jwt_secret_key,
|
| 225 |
algorithm=_settings.jwt_algorithm,
|
| 226 |
)
|
| 227 |
-
logger.info("Password reset token for %s: %s",
|
| 228 |
|
| 229 |
@staticmethod
|
| 230 |
-
async def reset_password(db:
|
| 231 |
try:
|
| 232 |
payload = jwt.decode(
|
| 233 |
schema.token,
|
|
@@ -240,86 +257,89 @@ class AuthService:
|
|
| 240 |
except jwt.PyJWTError:
|
| 241 |
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
if not user:
|
| 246 |
raise HTTPException(status_code=404, detail="User not found")
|
| 247 |
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
| 251 |
await db.execute(
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
)
|
| 256 |
-
await db.commit()
|
| 257 |
|
| 258 |
@staticmethod
|
| 259 |
-
async def update_profile(db:
|
| 260 |
if schema.username is not None:
|
| 261 |
-
|
| 262 |
-
|
|
|
|
| 263 |
)
|
| 264 |
-
if
|
| 265 |
raise HTTPException(status_code=409, detail="Username already taken")
|
| 266 |
-
|
| 267 |
if schema.full_name is not None:
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
|
|
|
|
|
|
| 272 |
|
| 273 |
@staticmethod
|
| 274 |
-
async def soft_delete(db:
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
| 277 |
await db.execute(
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
)
|
| 282 |
-
await db.commit()
|
| 283 |
|
| 284 |
@staticmethod
|
| 285 |
-
async def list_sessions(db:
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
RefreshSession.revoked_at.is_(None),
|
| 291 |
-
)
|
| 292 |
-
.order_by(RefreshSession.created_at.desc())
|
| 293 |
)
|
| 294 |
-
return
|
| 295 |
|
| 296 |
@staticmethod
|
| 297 |
-
async def revoke_session(db:
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
RefreshSession.user_id == user.id,
|
| 302 |
-
RefreshSession.revoked_at.is_(None),
|
| 303 |
-
)
|
| 304 |
)
|
| 305 |
-
|
| 306 |
-
if not session:
|
| 307 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
| 310 |
|
| 311 |
@staticmethod
|
| 312 |
-
async def cleanup_expired_sessions(db:
|
| 313 |
-
|
| 314 |
-
|
|
|
|
| 315 |
)
|
| 316 |
count = 0
|
| 317 |
-
for
|
| 318 |
-
if
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
| 320 |
count += 1
|
| 321 |
if count:
|
| 322 |
-
await db.commit()
|
| 323 |
logger.info("Cleaned up %s expired sessions", count)
|
| 324 |
|
| 325 |
@staticmethod
|
|
@@ -359,7 +379,19 @@ class AuthService:
|
|
| 359 |
return False
|
| 360 |
|
| 361 |
@staticmethod
|
| 362 |
-
def user_to_profile(user: User) -> UserProfile:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
return UserProfile(
|
| 364 |
id=user.id,
|
| 365 |
email=user.email,
|
|
@@ -367,6 +399,6 @@ class AuthService:
|
|
| 367 |
full_name=user.full_name,
|
| 368 |
is_active=user.is_active,
|
| 369 |
is_verified=user.is_verified,
|
| 370 |
-
roles=
|
| 371 |
created_at=user.created_at,
|
| 372 |
)
|
|
|
|
| 9 |
from argon2 import PasswordHasher
|
| 10 |
from argon2.exceptions import VerifyMismatchError
|
| 11 |
from fastapi import HTTPException, Request
|
|
|
|
|
|
|
| 12 |
|
| 13 |
from app.config import get_settings
|
| 14 |
+
from app.core.auth.deps import _row_to_refresh_session, _row_to_user, _serialize_dt
|
| 15 |
+
from app.core.auth.models import RefreshSession, User, _uuid
|
| 16 |
from app.core.auth.models import _utcnow as _now
|
|
|
|
| 17 |
from app.core.auth.schemas import (
|
| 18 |
ChangePasswordSchema,
|
| 19 |
ForgotPasswordSchema,
|
|
|
|
| 24 |
UpdateProfileSchema,
|
| 25 |
UserProfile,
|
| 26 |
)
|
| 27 |
+
from app.core.tidb_manager import TiDBManager
|
| 28 |
|
| 29 |
logger = logging.getLogger("auth_service")
|
| 30 |
_settings = get_settings()
|
|
|
|
| 38 |
class AuthService:
|
| 39 |
|
| 40 |
@staticmethod
|
| 41 |
+
async def register(db: TiDBManager, schema: RegisterSchema) -> User:
|
| 42 |
+
existing = await db.fetchone_all(
|
| 43 |
+
"SELECT id FROM users WHERE email = %s AND deleted_at IS NULL",
|
| 44 |
+
(schema.email,),
|
| 45 |
+
)
|
| 46 |
+
if existing:
|
| 47 |
raise HTTPException(status_code=409, detail="Email already registered")
|
| 48 |
|
| 49 |
if schema.username:
|
| 50 |
+
existing = await db.fetchone_all(
|
| 51 |
+
"SELECT id FROM users WHERE username = %s AND deleted_at IS NULL",
|
| 52 |
+
(schema.username,),
|
| 53 |
+
)
|
| 54 |
+
if existing:
|
| 55 |
raise HTTPException(status_code=409, detail="Username already taken")
|
| 56 |
|
| 57 |
+
user_id = _uuid()
|
| 58 |
+
now = _now()
|
| 59 |
+
await db.insert_and_get_id(
|
| 60 |
+
"INSERT INTO users (id, email, username, full_name, password_hash, created_at, updated_at) "
|
| 61 |
+
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
| 62 |
+
(user_id, schema.email, schema.username, schema.full_name, ph.hash(schema.password), now, now),
|
| 63 |
)
|
| 64 |
|
| 65 |
+
role_row = await db.fetchone("SELECT id FROM roles WHERE name = 'User'")
|
| 66 |
+
if role_row:
|
| 67 |
+
await db.execute(
|
| 68 |
+
"INSERT INTO user_roles (user_id, role_id) VALUES (%s, %s)",
|
| 69 |
+
(user_id, role_row["id"]),
|
| 70 |
+
)
|
| 71 |
|
| 72 |
+
user_row = await db.fetchone("SELECT * FROM users WHERE id = %s", (user_id,))
|
| 73 |
+
return _row_to_user(user_row)
|
|
|
|
|
|
|
| 74 |
|
| 75 |
@staticmethod
|
| 76 |
+
async def login(db: TiDBManager, request: Request, schema: LoginSchema) -> TokenData:
|
| 77 |
+
row = await db.fetchone_all(
|
| 78 |
+
"SELECT * FROM users WHERE email = %s AND deleted_at IS NULL",
|
| 79 |
+
(schema.email,),
|
| 80 |
)
|
| 81 |
+
if not row:
|
|
|
|
|
|
|
| 82 |
raise HTTPException(status_code=401, detail="Incorrect email or password")
|
| 83 |
|
| 84 |
+
user = _row_to_user(row)
|
| 85 |
+
|
| 86 |
if user.locked_until and user.locked_until > _now():
|
| 87 |
raise HTTPException(
|
| 88 |
status_code=403,
|
|
|
|
| 93 |
user.failed_login_attempts += 1
|
| 94 |
if user.failed_login_attempts >= _settings.max_login_attempts:
|
| 95 |
user.locked_until = _now() + timedelta(minutes=_settings.lockout_minutes)
|
| 96 |
+
await db.execute(
|
| 97 |
+
"UPDATE users SET failed_login_attempts = %s, locked_until = %s WHERE id = %s",
|
| 98 |
+
(user.failed_login_attempts, _serialize_dt(user.locked_until), user.id),
|
| 99 |
+
)
|
| 100 |
raise HTTPException(status_code=401, detail="Incorrect email or password")
|
| 101 |
|
| 102 |
+
now = _now()
|
| 103 |
+
await db.execute(
|
| 104 |
+
"UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login = %s "
|
| 105 |
+
"WHERE id = %s",
|
| 106 |
+
(now, user.id),
|
| 107 |
+
)
|
| 108 |
|
| 109 |
access_token = AuthService._create_access_token(user.id)
|
| 110 |
raw_refresh, refresh_hash, token_key, expires_at = AuthService._create_refresh_token()
|
| 111 |
|
| 112 |
+
session_id = _uuid()
|
| 113 |
+
await db.execute(
|
| 114 |
+
"INSERT INTO refresh_sessions (id, user_id, token_key, token_hash, device_info, "
|
| 115 |
+
"ip_address, expires_at, created_at, updated_at) "
|
| 116 |
+
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
| 117 |
+
(
|
| 118 |
+
session_id, user.id, token_key, refresh_hash,
|
| 119 |
+
request.headers.get("User-Agent", "Unknown"),
|
| 120 |
+
request.client.host if request.client else "Unknown",
|
| 121 |
+
_serialize_dt(expires_at), now, now,
|
| 122 |
+
),
|
| 123 |
)
|
|
|
|
|
|
|
| 124 |
|
| 125 |
return TokenData(access_token=access_token, refresh_token=raw_refresh)
|
| 126 |
|
| 127 |
@staticmethod
|
| 128 |
+
async def refresh(db: TiDBManager, raw_refresh_token: str) -> TokenData:
|
| 129 |
key = _token_key(raw_refresh_token)
|
| 130 |
|
| 131 |
+
row = await db.fetchone_all(
|
| 132 |
+
"SELECT * FROM refresh_sessions WHERE token_key = %s AND revoked_at IS NULL "
|
| 133 |
+
"AND expires_at > %s",
|
| 134 |
+
(key, _serialize_dt(_now())),
|
|
|
|
|
|
|
| 135 |
)
|
| 136 |
+
if not row:
|
| 137 |
+
comp = await db.fetchone_all(
|
| 138 |
+
"SELECT * FROM refresh_sessions WHERE token_key = %s", (key,),
|
|
|
|
|
|
|
| 139 |
)
|
| 140 |
+
if comp and comp.get("revoked_at") is not None:
|
| 141 |
+
now = _serialize_dt(_now())
|
| 142 |
+
await db.execute_on_all(
|
| 143 |
+
"UPDATE refresh_sessions SET revoked_at = %s "
|
| 144 |
+
"WHERE user_id = %s AND revoked_at IS NULL",
|
| 145 |
+
(now, comp["user_id"]),
|
| 146 |
)
|
|
|
|
| 147 |
raise HTTPException(
|
| 148 |
status_code=401,
|
| 149 |
detail="Session compromised. All sessions revoked. Please login again.",
|
| 150 |
)
|
| 151 |
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
| 152 |
|
| 153 |
+
session = _row_to_refresh_session(row)
|
| 154 |
+
|
| 155 |
if not AuthService._verify_token(raw_refresh_token, session.token_hash):
|
| 156 |
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
| 157 |
|
| 158 |
+
user_row = await db.fetchone_all(
|
| 159 |
+
"SELECT * FROM users WHERE id = %s AND deleted_at IS NULL AND is_active = 1",
|
| 160 |
+
(session.user_id,),
|
|
|
|
| 161 |
)
|
| 162 |
+
if not user_row:
|
|
|
|
| 163 |
raise HTTPException(status_code=401, detail="User not found or inactive")
|
| 164 |
+
user = _row_to_user(user_row)
|
| 165 |
|
| 166 |
+
now = _serialize_dt(_now())
|
| 167 |
+
await db.execute(
|
| 168 |
+
"UPDATE refresh_sessions SET revoked_at = %s WHERE id = %s",
|
| 169 |
+
(now, session.id),
|
| 170 |
+
)
|
| 171 |
|
| 172 |
new_access = AuthService._create_access_token(user.id)
|
| 173 |
new_raw_refresh, new_hash, new_key, new_expires = AuthService._create_refresh_token()
|
| 174 |
|
| 175 |
+
new_session_id = _uuid()
|
| 176 |
+
await db.execute(
|
| 177 |
+
"INSERT INTO refresh_sessions (id, user_id, token_key, token_hash, device_info, "
|
| 178 |
+
"ip_address, expires_at, created_at, updated_at) "
|
| 179 |
+
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
| 180 |
+
(
|
| 181 |
+
new_session_id, user.id, new_key, new_hash,
|
| 182 |
+
session.device_info, session.ip_address,
|
| 183 |
+
_serialize_dt(new_expires), _serialize_dt(_now()), _serialize_dt(_now()),
|
| 184 |
+
),
|
| 185 |
)
|
|
|
|
| 186 |
|
| 187 |
+
await db.execute(
|
| 188 |
+
"UPDATE users SET last_login = %s WHERE id = %s",
|
| 189 |
+
(_serialize_dt(_now()), user.id),
|
| 190 |
+
)
|
| 191 |
|
| 192 |
return TokenData(access_token=new_access, refresh_token=new_raw_refresh)
|
| 193 |
|
| 194 |
@staticmethod
|
| 195 |
+
async def logout(db: TiDBManager, user: User, raw_refresh_token: str):
|
| 196 |
key = _token_key(raw_refresh_token)
|
| 197 |
+
row = await db.fetchone_all(
|
| 198 |
+
"SELECT id FROM refresh_sessions WHERE token_key = %s "
|
| 199 |
+
"AND user_id = %s AND revoked_at IS NULL",
|
| 200 |
+
(key, user.id),
|
|
|
|
|
|
|
| 201 |
)
|
| 202 |
+
if not row:
|
|
|
|
| 203 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 204 |
+
await db.execute(
|
| 205 |
+
"UPDATE refresh_sessions SET revoked_at = %s WHERE id = %s",
|
| 206 |
+
(_serialize_dt(_now()), row["id"]),
|
| 207 |
+
)
|
| 208 |
|
| 209 |
@staticmethod
|
| 210 |
+
async def logout_all(db: TiDBManager, user: User):
|
| 211 |
+
now = _serialize_dt(_now())
|
| 212 |
await db.execute(
|
| 213 |
+
"UPDATE refresh_sessions SET revoked_at = %s "
|
| 214 |
+
"WHERE user_id = %s AND revoked_at IS NULL",
|
| 215 |
+
(now, user.id),
|
|
|
|
|
|
|
|
|
|
| 216 |
)
|
|
|
|
| 217 |
|
| 218 |
@staticmethod
|
| 219 |
+
async def change_password(db: TiDBManager, user: User, schema: ChangePasswordSchema):
|
| 220 |
if not AuthService._verify_password(schema.current_password, user.password_hash):
|
| 221 |
raise HTTPException(status_code=400, detail="Incorrect current password")
|
| 222 |
+
now = _serialize_dt(_now())
|
| 223 |
+
await db.execute(
|
| 224 |
+
"UPDATE users SET password_hash = %s, password_changed_at = %s WHERE id = %s",
|
| 225 |
+
(ph.hash(schema.new_password), now, user.id),
|
| 226 |
+
)
|
| 227 |
|
| 228 |
@staticmethod
|
| 229 |
+
async def forgot_password(db: TiDBManager, schema: ForgotPasswordSchema):
|
| 230 |
+
row = await db.fetchone_all(
|
| 231 |
+
"SELECT id FROM users WHERE email = %s AND deleted_at IS NULL",
|
| 232 |
+
(schema.email,),
|
| 233 |
)
|
| 234 |
+
if row:
|
|
|
|
| 235 |
reset_token = jwt.encode(
|
| 236 |
{
|
| 237 |
+
"sub": row["id"],
|
| 238 |
"type": "reset_password",
|
| 239 |
"exp": _now() + timedelta(hours=1),
|
| 240 |
},
|
| 241 |
_settings.jwt_secret_key,
|
| 242 |
algorithm=_settings.jwt_algorithm,
|
| 243 |
)
|
| 244 |
+
logger.info("Password reset token for %s: %s", schema.email, reset_token)
|
| 245 |
|
| 246 |
@staticmethod
|
| 247 |
+
async def reset_password(db: TiDBManager, schema: ResetPasswordSchema):
|
| 248 |
try:
|
| 249 |
payload = jwt.decode(
|
| 250 |
schema.token,
|
|
|
|
| 257 |
except jwt.PyJWTError:
|
| 258 |
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
| 259 |
|
| 260 |
+
row = await db.fetchone_all("SELECT id FROM users WHERE id = %s", (user_id,))
|
| 261 |
+
if not row:
|
|
|
|
| 262 |
raise HTTPException(status_code=404, detail="User not found")
|
| 263 |
|
| 264 |
+
now = _serialize_dt(_now())
|
| 265 |
+
await db.execute(
|
| 266 |
+
"UPDATE users SET password_hash = %s, password_changed_at = %s WHERE id = %s",
|
| 267 |
+
(ph.hash(schema.new_password), now, user_id),
|
| 268 |
+
)
|
| 269 |
await db.execute(
|
| 270 |
+
"UPDATE refresh_sessions SET revoked_at = %s "
|
| 271 |
+
"WHERE user_id = %s AND revoked_at IS NULL",
|
| 272 |
+
(now, user_id),
|
| 273 |
)
|
|
|
|
| 274 |
|
| 275 |
@staticmethod
|
| 276 |
+
async def update_profile(db: TiDBManager, user: User, schema: UpdateProfileSchema) -> User:
|
| 277 |
if schema.username is not None:
|
| 278 |
+
existing = await db.fetchone_all(
|
| 279 |
+
"SELECT id FROM users WHERE username = %s AND id != %s AND deleted_at IS NULL",
|
| 280 |
+
(schema.username, user.id),
|
| 281 |
)
|
| 282 |
+
if existing:
|
| 283 |
raise HTTPException(status_code=409, detail="Username already taken")
|
| 284 |
+
await db.execute("UPDATE users SET username = %s WHERE id = %s", (schema.username, user.id))
|
| 285 |
if schema.full_name is not None:
|
| 286 |
+
await db.execute(
|
| 287 |
+
"UPDATE users SET full_name = %s WHERE id = %s",
|
| 288 |
+
(schema.full_name, user.id),
|
| 289 |
+
)
|
| 290 |
+
row = await db.fetchone("SELECT * FROM users WHERE id = %s", (user.id,))
|
| 291 |
+
return _row_to_user(row)
|
| 292 |
|
| 293 |
@staticmethod
|
| 294 |
+
async def soft_delete(db: TiDBManager, user: User):
|
| 295 |
+
now = _serialize_dt(_now())
|
| 296 |
+
await db.execute(
|
| 297 |
+
"UPDATE users SET deleted_at = %s, is_active = 0 WHERE id = %s",
|
| 298 |
+
(now, user.id),
|
| 299 |
+
)
|
| 300 |
await db.execute(
|
| 301 |
+
"UPDATE refresh_sessions SET revoked_at = %s "
|
| 302 |
+
"WHERE user_id = %s AND revoked_at IS NULL",
|
| 303 |
+
(now, user.id),
|
| 304 |
)
|
|
|
|
| 305 |
|
| 306 |
@staticmethod
|
| 307 |
+
async def list_sessions(db: TiDBManager, user: User) -> list[RefreshSession]:
|
| 308 |
+
rows = await db.fetchall_all(
|
| 309 |
+
"SELECT * FROM refresh_sessions WHERE user_id = %s AND revoked_at IS NULL "
|
| 310 |
+
"ORDER BY created_at DESC",
|
| 311 |
+
(user.id,),
|
|
|
|
|
|
|
|
|
|
| 312 |
)
|
| 313 |
+
return [_row_to_refresh_session(r) for r in rows]
|
| 314 |
|
| 315 |
@staticmethod
|
| 316 |
+
async def revoke_session(db: TiDBManager, user: User, session_id: str):
|
| 317 |
+
row = await db.fetchone_all(
|
| 318 |
+
"SELECT id FROM refresh_sessions WHERE id = %s AND user_id = %s AND revoked_at IS NULL",
|
| 319 |
+
(session_id, user.id),
|
|
|
|
|
|
|
|
|
|
| 320 |
)
|
| 321 |
+
if not row:
|
|
|
|
| 322 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 323 |
+
await db.execute(
|
| 324 |
+
"UPDATE refresh_sessions SET revoked_at = %s WHERE id = %s",
|
| 325 |
+
(_serialize_dt(_now()), session_id),
|
| 326 |
+
)
|
| 327 |
|
| 328 |
@staticmethod
|
| 329 |
+
async def cleanup_expired_sessions(db: TiDBManager):
|
| 330 |
+
rows = await db.fetchall_all(
|
| 331 |
+
"SELECT * FROM refresh_sessions WHERE expires_at < %s",
|
| 332 |
+
(_serialize_dt(_now()),),
|
| 333 |
)
|
| 334 |
count = 0
|
| 335 |
+
for row in rows:
|
| 336 |
+
if row.get("revoked_at") is None:
|
| 337 |
+
await db.execute(
|
| 338 |
+
"UPDATE refresh_sessions SET revoked_at = %s WHERE id = %s",
|
| 339 |
+
(_serialize_dt(_now()), row["id"]),
|
| 340 |
+
)
|
| 341 |
count += 1
|
| 342 |
if count:
|
|
|
|
| 343 |
logger.info("Cleaned up %s expired sessions", count)
|
| 344 |
|
| 345 |
@staticmethod
|
|
|
|
| 379 |
return False
|
| 380 |
|
| 381 |
@staticmethod
|
| 382 |
+
async def user_to_profile(db: TiDBManager, user: User) -> UserProfile:
|
| 383 |
+
roles = []
|
| 384 |
+
if db:
|
| 385 |
+
try:
|
| 386 |
+
rows = await db.fetchall_all(
|
| 387 |
+
"SELECT r.name FROM roles r "
|
| 388 |
+
"JOIN user_roles ur ON ur.role_id = r.id "
|
| 389 |
+
"WHERE ur.user_id = %s",
|
| 390 |
+
(user.id,),
|
| 391 |
+
)
|
| 392 |
+
roles = [r["name"] for r in rows]
|
| 393 |
+
except Exception:
|
| 394 |
+
pass
|
| 395 |
return UserProfile(
|
| 396 |
id=user.id,
|
| 397 |
email=user.email,
|
|
|
|
| 399 |
full_name=user.full_name,
|
| 400 |
is_active=user.is_active,
|
| 401 |
is_verified=user.is_verified,
|
| 402 |
+
roles=roles,
|
| 403 |
created_at=user.created_at,
|
| 404 |
)
|
app/services/csv_analysis_service.py
CHANGED
|
@@ -5,8 +5,8 @@ import json
|
|
| 5 |
import logging
|
| 6 |
import os
|
| 7 |
import shutil
|
| 8 |
-
import subprocess
|
| 9 |
import signal
|
|
|
|
| 10 |
import sys
|
| 11 |
import tempfile
|
| 12 |
import time
|
|
|
|
| 5 |
import logging
|
| 6 |
import os
|
| 7 |
import shutil
|
|
|
|
| 8 |
import signal
|
| 9 |
+
import subprocess
|
| 10 |
import sys
|
| 11 |
import tempfile
|
| 12 |
import time
|
app/services/dataset_metadata_service.py
CHANGED
|
@@ -31,9 +31,9 @@ from urllib.parse import unquote, urlparse
|
|
| 31 |
import aiohttp
|
| 32 |
import chardet
|
| 33 |
import numpy as np
|
|
|
|
| 34 |
import pandas as pd
|
| 35 |
import xlrd # noqa: F401 – needed as engine for .xls
|
| 36 |
-
import openpyxl # noqa: F401 – needed as engine for .xlsx
|
| 37 |
|
| 38 |
logger = logging.getLogger(__name__)
|
| 39 |
logger.setLevel(logging.DEBUG)
|
|
|
|
| 31 |
import aiohttp
|
| 32 |
import chardet
|
| 33 |
import numpy as np
|
| 34 |
+
import openpyxl # noqa: F401 – needed as engine for .xlsx
|
| 35 |
import pandas as pd
|
| 36 |
import xlrd # noqa: F401 – needed as engine for .xls
|
|
|
|
| 37 |
|
| 38 |
logger = logging.getLogger(__name__)
|
| 39 |
logger.setLevel(logging.DEBUG)
|
app/services/prompts/csv_system_prompt.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
-
import os
|
| 5 |
from typing import Any, Dict
|
| 6 |
|
| 7 |
_JSON_EXAMPLE = (
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
|
|
|
| 4 |
from typing import Any, Dict
|
| 5 |
|
| 6 |
_JSON_EXAMPLE = (
|
app/services/py_sandbox.py
CHANGED
|
@@ -1,31 +1,31 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
import
|
| 3 |
-
import io
|
| 4 |
-
import os
|
| 5 |
-
import math as _math
|
| 6 |
-
import json as _json
|
| 7 |
-
import re as _re
|
| 8 |
-
import random as _random
|
| 9 |
-
import collections as _collections
|
| 10 |
-
import itertools as _itertools
|
| 11 |
-
import functools as _functools
|
| 12 |
-
import operator as _operator
|
| 13 |
-
import string as _string
|
| 14 |
-
import decimal as _decimal
|
| 15 |
-
import fractions as _fractions
|
| 16 |
-
import statistics as _statistics
|
| 17 |
-
import heapq as _heapq
|
| 18 |
import bisect as _bisect
|
|
|
|
| 19 |
import copy as _copy
|
| 20 |
-
import typing as _typing
|
| 21 |
-
import textwrap as _textwrap
|
| 22 |
import datetime as _datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
import hashlib as _hashlib
|
| 24 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
import secrets as _secrets
|
| 26 |
-
import
|
| 27 |
-
import
|
|
|
|
|
|
|
| 28 |
import time as _time
|
|
|
|
|
|
|
| 29 |
|
| 30 |
_SAFE_MODULES = {
|
| 31 |
'math': _math, 'json': _json, 're': _re, 'random': _random,
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
import base64 as _base64
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import bisect as _bisect
|
| 4 |
+
import collections as _collections
|
| 5 |
import copy as _copy
|
|
|
|
|
|
|
| 6 |
import datetime as _datetime
|
| 7 |
+
import decimal as _decimal
|
| 8 |
+
import enum as _enum
|
| 9 |
+
import fractions as _fractions
|
| 10 |
+
import functools as _functools
|
| 11 |
import hashlib as _hashlib
|
| 12 |
+
import heapq as _heapq
|
| 13 |
+
import io
|
| 14 |
+
import itertools as _itertools
|
| 15 |
+
import json as _json
|
| 16 |
+
import math as _math
|
| 17 |
+
import operator as _operator
|
| 18 |
+
import os
|
| 19 |
+
import random as _random
|
| 20 |
+
import re as _re
|
| 21 |
import secrets as _secrets
|
| 22 |
+
import statistics as _statistics
|
| 23 |
+
import string as _string
|
| 24 |
+
import sys
|
| 25 |
+
import textwrap as _textwrap
|
| 26 |
import time as _time
|
| 27 |
+
import typing as _typing
|
| 28 |
+
import uuid as _uuid
|
| 29 |
|
| 30 |
_SAFE_MODULES = {
|
| 31 |
'math': _math, 'json': _json, 're': _re, 'random': _random,
|
app/services/text_cleaner_service.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
-
import re
|
| 2 |
import html
|
|
|
|
|
|
|
| 3 |
from cleantext import clean
|
| 4 |
from text_unidecode import unidecode
|
| 5 |
|
|
|
|
|
|
|
| 1 |
import html
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
from cleantext import clean
|
| 5 |
from text_unidecode import unidecode
|
| 6 |
|
app/services/url_shortener_service.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import csv
|
| 4 |
import hashlib
|
| 5 |
import hmac
|
|
@@ -9,11 +10,9 @@ import json
|
|
| 9 |
import os
|
| 10 |
import secrets
|
| 11 |
import socket
|
| 12 |
-
import sqlite3
|
| 13 |
import string
|
| 14 |
import threading
|
| 15 |
import unicodedata
|
| 16 |
-
from contextlib import contextmanager
|
| 17 |
from dataclasses import dataclass
|
| 18 |
from datetime import datetime, timedelta, timezone
|
| 19 |
from typing import Any, Dict, List, Optional
|
|
@@ -21,6 +20,7 @@ from urllib.parse import urlparse
|
|
| 21 |
|
| 22 |
from app.config import get_settings
|
| 23 |
from app.core.logger import get_logger
|
|
|
|
| 24 |
|
| 25 |
logger = get_logger(__name__)
|
| 26 |
|
|
@@ -278,15 +278,12 @@ def _apply_geo_targeting(rules: Dict[str, Any], default_url: str,
|
|
| 278 |
ua_lower = (user_agent or "").lower()
|
| 279 |
is_mobile = "mobile" in ua_lower or "android" in ua_lower or "iphone" in ua_lower
|
| 280 |
is_tablet = "tablet" in ua_lower or "ipad" in ua_lower
|
| 281 |
-
# Device rule takes precedence
|
| 282 |
if is_tablet and "tablet" in devices:
|
| 283 |
return devices["tablet"]
|
| 284 |
if is_mobile and "mobile" in devices:
|
| 285 |
return devices["mobile"]
|
| 286 |
if not (is_mobile or is_tablet) and "desktop" in devices:
|
| 287 |
return devices["desktop"]
|
| 288 |
-
# Country-specific override (requires IP geolocation service)
|
| 289 |
-
# For now, limited to simplistic check
|
| 290 |
return default_url
|
| 291 |
|
| 292 |
|
|
@@ -308,266 +305,290 @@ def _fire_click_webhook(webhook_url: str, short_code: str, long_url: str,
|
|
| 308 |
client.post(webhook_url, json=payload)
|
| 309 |
except ImportError:
|
| 310 |
import urllib.request
|
| 311 |
-
|
| 312 |
-
data = _json.dumps(payload).encode()
|
| 313 |
req = urllib.request.Request(webhook_url, data=data,
|
| 314 |
headers={"Content-Type": "application/json"},
|
| 315 |
method="POST")
|
| 316 |
urllib.request.urlopen(req, timeout=5)
|
| 317 |
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
class Storage:
|
| 320 |
-
def __init__(self
|
| 321 |
-
self._db_path = db_path
|
| 322 |
self._lock = threading.RLock()
|
| 323 |
-
self._local = threading.local()
|
| 324 |
-
self._init_schema()
|
| 325 |
-
|
| 326 |
-
@property
|
| 327 |
-
def _conn(self) -> sqlite3.Connection:
|
| 328 |
-
if not hasattr(self._local, "conn"):
|
| 329 |
-
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
| 330 |
-
conn.row_factory = sqlite3.Row
|
| 331 |
-
conn.execute("PRAGMA foreign_keys = ON")
|
| 332 |
-
conn.execute("PRAGMA journal_mode = WAL")
|
| 333 |
-
self._local.conn = conn
|
| 334 |
-
return self._local.conn
|
| 335 |
-
|
| 336 |
-
@contextmanager
|
| 337 |
-
def writer_lock(self):
|
| 338 |
-
with self._lock:
|
| 339 |
-
yield
|
| 340 |
-
|
| 341 |
-
def _init_schema(self) -> None:
|
| 342 |
-
with self._lock, self._conn as conn:
|
| 343 |
-
conn.execute("""
|
| 344 |
-
CREATE TABLE IF NOT EXISTS url_shortener_owners (
|
| 345 |
-
owner_id TEXT PRIMARY KEY,
|
| 346 |
-
name TEXT NOT NULL,
|
| 347 |
-
api_key_hash TEXT NOT NULL UNIQUE,
|
| 348 |
-
plan TEXT NOT NULL DEFAULT 'free',
|
| 349 |
-
created_at TEXT NOT NULL
|
| 350 |
-
)
|
| 351 |
-
""")
|
| 352 |
-
conn.execute("""
|
| 353 |
-
CREATE TABLE IF NOT EXISTS url_shortener_links (
|
| 354 |
-
short_code TEXT PRIMARY KEY,
|
| 355 |
-
long_url TEXT NOT NULL,
|
| 356 |
-
owner_id TEXT NOT NULL,
|
| 357 |
-
created_at TEXT NOT NULL,
|
| 358 |
-
expires_at TEXT,
|
| 359 |
-
max_clicks INTEGER,
|
| 360 |
-
click_count INTEGER NOT NULL DEFAULT 0,
|
| 361 |
-
last_accessed_at TEXT,
|
| 362 |
-
is_active INTEGER NOT NULL DEFAULT 1,
|
| 363 |
-
tags TEXT,
|
| 364 |
-
note TEXT,
|
| 365 |
-
password_hash TEXT,
|
| 366 |
-
utm_source TEXT,
|
| 367 |
-
utm_medium TEXT,
|
| 368 |
-
utm_campaign TEXT,
|
| 369 |
-
campaign_id TEXT,
|
| 370 |
-
custom_domain TEXT,
|
| 371 |
-
fallback_url TEXT,
|
| 372 |
-
webhook_url TEXT,
|
| 373 |
-
geo_targeting TEXT,
|
| 374 |
-
FOREIGN KEY (owner_id) REFERENCES url_shortener_owners(owner_id)
|
| 375 |
-
)
|
| 376 |
-
""")
|
| 377 |
-
conn.execute("""
|
| 378 |
-
CREATE TABLE IF NOT EXISTS url_shortener_clicks (
|
| 379 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 380 |
-
short_code TEXT NOT NULL,
|
| 381 |
-
referrer TEXT,
|
| 382 |
-
user_agent TEXT,
|
| 383 |
-
ip_address TEXT,
|
| 384 |
-
country TEXT,
|
| 385 |
-
browser TEXT,
|
| 386 |
-
device TEXT,
|
| 387 |
-
os TEXT,
|
| 388 |
-
clicked_at TEXT NOT NULL,
|
| 389 |
-
FOREIGN KEY (short_code) REFERENCES url_shortener_links(short_code)
|
| 390 |
-
)
|
| 391 |
-
""")
|
| 392 |
-
conn.execute("""
|
| 393 |
-
CREATE TABLE IF NOT EXISTS url_shortener_audit_log (
|
| 394 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 395 |
-
timestamp TEXT NOT NULL,
|
| 396 |
-
owner_id TEXT,
|
| 397 |
-
action TEXT NOT NULL,
|
| 398 |
-
short_code TEXT,
|
| 399 |
-
detail TEXT
|
| 400 |
-
)
|
| 401 |
-
""")
|
| 402 |
-
conn.execute("""
|
| 403 |
-
CREATE TABLE IF NOT EXISTS url_shortener_campaigns (
|
| 404 |
-
campaign_id TEXT PRIMARY KEY,
|
| 405 |
-
owner_id TEXT NOT NULL,
|
| 406 |
-
name TEXT NOT NULL,
|
| 407 |
-
description TEXT,
|
| 408 |
-
created_at TEXT NOT NULL,
|
| 409 |
-
is_active INTEGER NOT NULL DEFAULT 1,
|
| 410 |
-
FOREIGN KEY (owner_id) REFERENCES url_shortener_owners(owner_id)
|
| 411 |
-
)
|
| 412 |
-
""")
|
| 413 |
-
conn.execute("CREATE INDEX IF NOT EXISTS idx_us_links_owner ON url_shortener_links(owner_id)")
|
| 414 |
-
conn.execute("CREATE INDEX IF NOT EXISTS idx_us_clicks_code ON url_shortener_clicks(short_code)")
|
| 415 |
-
conn.execute("CREATE INDEX IF NOT EXISTS idx_us_clicks_at ON url_shortener_clicks(clicked_at)")
|
| 416 |
-
conn.execute("CREATE INDEX IF NOT EXISTS idx_us_links_campaign ON url_shortener_links(campaign_id)")
|
| 417 |
-
self._migrate_schema(conn)
|
| 418 |
-
|
| 419 |
-
def _migrate_schema(self, conn: sqlite3.Connection) -> None:
|
| 420 |
-
existing = {r["name"] for r in conn.execute("PRAGMA table_info(url_shortener_links)").fetchall()}
|
| 421 |
-
migrations = {
|
| 422 |
-
"campaign_id": "ALTER TABLE url_shortener_links ADD COLUMN campaign_id TEXT",
|
| 423 |
-
"custom_domain": "ALTER TABLE url_shortener_links ADD COLUMN custom_domain TEXT",
|
| 424 |
-
"fallback_url": "ALTER TABLE url_shortener_links ADD COLUMN fallback_url TEXT",
|
| 425 |
-
"webhook_url": "ALTER TABLE url_shortener_links ADD COLUMN webhook_url TEXT",
|
| 426 |
-
"geo_targeting": "ALTER TABLE url_shortener_links ADD COLUMN geo_targeting TEXT",
|
| 427 |
-
}
|
| 428 |
-
for col, ddl in migrations.items():
|
| 429 |
-
if col not in existing:
|
| 430 |
-
conn.execute(ddl)
|
| 431 |
|
| 432 |
-
def
|
| 433 |
-
|
| 434 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
"INSERT INTO url_shortener_owners (owner_id, name, api_key_hash, plan, created_at) "
|
| 436 |
-
"VALUES (
|
| 437 |
(owner_id, name, api_key_hash, plan, _now_iso()),
|
| 438 |
-
)
|
| 439 |
|
| 440 |
-
def get_owner_by_key_hash(self, api_key_hash: str) -> Optional[
|
| 441 |
-
return self.
|
| 442 |
-
"SELECT * FROM url_shortener_owners WHERE api_key_hash =
|
| 443 |
-
|
|
|
|
| 444 |
|
| 445 |
-
def get_owner(self, owner_id: str) -> Optional[
|
| 446 |
-
return self.
|
| 447 |
-
"SELECT * FROM url_shortener_owners WHERE owner_id =
|
| 448 |
-
|
|
|
|
| 449 |
|
| 450 |
-
def update_owner_plan(self, owner_id: str, plan: str)
|
| 451 |
-
with self._lock
|
| 452 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
|
| 454 |
def code_exists(self, short_code: str) -> bool:
|
| 455 |
-
|
| 456 |
-
"SELECT 1 FROM url_shortener_links WHERE short_code =
|
| 457 |
-
|
|
|
|
|
|
|
| 458 |
|
| 459 |
-
def insert_link(self, **kwargs
|
| 460 |
-
with self._lock
|
| 461 |
-
|
| 462 |
INSERT INTO url_shortener_links
|
| 463 |
(short_code, long_url, owner_id, created_at, expires_at,
|
| 464 |
max_clicks, click_count, last_accessed_at, is_active, tags, note,
|
| 465 |
password_hash, utm_source, utm_medium, utm_campaign,
|
| 466 |
campaign_id, custom_domain, fallback_url, webhook_url, geo_targeting)
|
| 467 |
VALUES
|
| 468 |
-
(
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
""", kwargs)
|
| 474 |
-
|
| 475 |
-
def update_link(self, short_code: str, **updates
|
| 476 |
-
with self._lock
|
| 477 |
-
sets = ", ".join(f"{k} =
|
| 478 |
vals = list(updates.values()) + [short_code]
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
(owner_id,),
|
| 490 |
-
)
|
| 491 |
|
| 492 |
-
def deactivate_link(self, short_code: str)
|
| 493 |
-
with self._lock
|
| 494 |
-
|
|
|
|
|
|
|
|
|
|
| 495 |
|
| 496 |
def record_click(self, short_code: str, referrer: Optional[str] = None,
|
| 497 |
-
user_agent: Optional[str] = None, ip_address: Optional[str] = None)
|
| 498 |
now = _now_iso()
|
| 499 |
parsed = _parse_user_agent(user_agent or "")
|
| 500 |
-
with self._lock
|
| 501 |
-
|
| 502 |
"INSERT INTO url_shortener_clicks "
|
| 503 |
"(short_code, referrer, user_agent, ip_address, country, browser, device, os, clicked_at) "
|
| 504 |
-
"VALUES (
|
| 505 |
(short_code, referrer, user_agent, ip_address, None,
|
| 506 |
parsed["browser"], parsed["device"], parsed["os"], now),
|
| 507 |
-
)
|
| 508 |
-
|
| 509 |
-
"UPDATE url_shortener_links SET click_count = click_count + 1, last_accessed_at =
|
| 510 |
-
"WHERE short_code =
|
| 511 |
(now, short_code),
|
| 512 |
-
)
|
| 513 |
|
| 514 |
def get_click_analytics(self, short_code: str) -> Dict[str, Any]:
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
|
|
|
|
|
|
|
|
|
| 529 |
"SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS ref, COUNT(*) AS n "
|
| 530 |
-
"FROM url_shortener_clicks WHERE short_code =
|
| 531 |
"GROUP BY ref ORDER BY n DESC LIMIT 10",
|
| 532 |
-
(short_code,)
|
| 533 |
-
)
|
| 534 |
-
|
| 535 |
-
"SELECT os, COUNT(*) FROM url_shortener_clicks WHERE short_code =
|
| 536 |
-
"AND os IS NOT NULL GROUP BY os ORDER BY
|
| 537 |
-
(short_code,)
|
| 538 |
-
)
|
| 539 |
-
recent =
|
| 540 |
-
"SELECT clicked_at FROM url_shortener_clicks WHERE short_code =
|
| 541 |
"ORDER BY clicked_at DESC LIMIT 50",
|
| 542 |
-
(short_code,)
|
| 543 |
-
)
|
| 544 |
return {
|
| 545 |
-
"total_clicks":
|
| 546 |
-
"browsers":
|
| 547 |
-
"devices":
|
| 548 |
-
"operating_systems":
|
| 549 |
-
"top_referrers":
|
| 550 |
"recent_clicks": [r["clicked_at"] for r in recent],
|
| 551 |
}
|
| 552 |
|
| 553 |
def get_link_count_for_owner(self, owner_id: str) -> int:
|
| 554 |
-
|
| 555 |
-
"SELECT COUNT(*) FROM url_shortener_links WHERE owner_id =
|
| 556 |
-
|
|
|
|
|
|
|
| 557 |
|
| 558 |
-
def audit(self, owner_id: Optional[str], action: str, short_code: Optional[str], detail: str = "")
|
| 559 |
-
with self._lock
|
| 560 |
-
|
| 561 |
"INSERT INTO url_shortener_audit_log (timestamp, owner_id, action, short_code, detail) "
|
| 562 |
-
"VALUES (
|
| 563 |
(_now_iso(), owner_id, action, short_code, detail),
|
| 564 |
-
)
|
| 565 |
|
| 566 |
def export_links_csv(self, owner_id: str) -> str:
|
| 567 |
-
rows = self.
|
| 568 |
"SELECT short_code, long_url, created_at, click_count, is_active, tags, note "
|
| 569 |
-
"FROM url_shortener_links WHERE owner_id =
|
| 570 |
-
|
|
|
|
| 571 |
buf = io.StringIO()
|
| 572 |
w = csv.writer(buf)
|
| 573 |
w.writerow(["short_code", "long_url", "created_at", "click_count", "is_active", "tags", "note"])
|
|
@@ -577,10 +598,11 @@ class Storage:
|
|
| 577 |
return buf.getvalue()
|
| 578 |
|
| 579 |
def export_clicks_csv(self, short_code: str) -> str:
|
| 580 |
-
rows = self.
|
| 581 |
"SELECT id, referrer, user_agent, ip_address, browser, device, os, clicked_at "
|
| 582 |
-
"FROM url_shortener_clicks WHERE short_code =
|
| 583 |
-
|
|
|
|
| 584 |
buf = io.StringIO()
|
| 585 |
w = csv.writer(buf)
|
| 586 |
w.writerow(["id", "referrer", "user_agent", "ip_address", "browser", "device", "os", "clicked_at"])
|
|
@@ -589,76 +611,85 @@ class Storage:
|
|
| 589 |
r["browser"], r["device"], r["os"], r["clicked_at"]])
|
| 590 |
return buf.getvalue()
|
| 591 |
|
| 592 |
-
def list_links_by_campaign(self, campaign_id: str) -> List[
|
| 593 |
-
return self.
|
| 594 |
-
"SELECT * FROM url_shortener_links WHERE campaign_id =
|
| 595 |
(campaign_id,),
|
| 596 |
-
)
|
| 597 |
|
| 598 |
-
def create_campaign(self, campaign_id: str, owner_id: str, name: str, description: Optional[str] = None)
|
| 599 |
-
with self._lock
|
| 600 |
-
|
| 601 |
"INSERT INTO url_shortener_campaigns (campaign_id, owner_id, name, description, created_at) "
|
| 602 |
-
"VALUES (
|
| 603 |
(campaign_id, owner_id, name, description, _now_iso()),
|
| 604 |
-
)
|
| 605 |
|
| 606 |
-
def get_campaign(self, campaign_id: str) -> Optional[
|
| 607 |
-
return self.
|
| 608 |
-
"SELECT * FROM url_shortener_campaigns WHERE campaign_id =
|
| 609 |
-
|
|
|
|
| 610 |
|
| 611 |
-
def list_campaigns_for_owner(self, owner_id: str) -> List[
|
| 612 |
-
return self.
|
| 613 |
-
"SELECT * FROM url_shortener_campaigns WHERE owner_id =
|
| 614 |
(owner_id,),
|
| 615 |
-
)
|
| 616 |
|
| 617 |
-
def deactivate_campaign(self, campaign_id: str)
|
| 618 |
-
with self._lock
|
| 619 |
-
|
| 620 |
-
"UPDATE url_shortener_campaigns SET is_active = 0 WHERE campaign_id =
|
| 621 |
(campaign_id,),
|
| 622 |
-
)
|
| 623 |
|
| 624 |
def get_campaign_link_count(self, campaign_id: str) -> int:
|
| 625 |
-
|
| 626 |
-
"SELECT COUNT(*) FROM url_shortener_links WHERE campaign_id =
|
| 627 |
-
|
|
|
|
|
|
|
| 628 |
|
| 629 |
def get_campaign_total_clicks(self, campaign_id: str) -> int:
|
| 630 |
-
|
| 631 |
-
"SELECT COALESCE(SUM(click_count), 0) FROM url_shortener_links WHERE campaign_id =
|
| 632 |
(campaign_id,),
|
| 633 |
-
)
|
|
|
|
| 634 |
|
| 635 |
def get_campaign_analytics(self, campaign_id: str) -> Dict[str, Any]:
|
| 636 |
-
codes = self.
|
| 637 |
-
"SELECT short_code FROM url_shortener_links WHERE campaign_id =
|
| 638 |
-
|
|
|
|
| 639 |
total = 0
|
| 640 |
browsers: Dict[str, int] = {}
|
| 641 |
devices: Dict[str, int] = {}
|
| 642 |
os_data: Dict[str, int] = {}
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
"
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
"
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
return {"total_clicks": total, "browsers": browsers, "devices": devices, "operating_systems": os_data}
|
| 663 |
|
| 664 |
def owner_summary(self, owner_id: str) -> Dict[str, Any]:
|
|
@@ -666,30 +697,31 @@ class Storage:
|
|
| 666 |
if row is None:
|
| 667 |
return {}
|
| 668 |
link_count = self.get_link_count_for_owner(owner_id)
|
| 669 |
-
|
| 670 |
-
"SELECT COALESCE(SUM(click_count), 0) FROM url_shortener_links WHERE owner_id =
|
| 671 |
-
(owner_id,)
|
| 672 |
-
)
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
(owner_id,
|
| 676 |
-
|
|
|
|
| 677 |
return {
|
| 678 |
"owner_id": owner_id,
|
| 679 |
"name": row["name"],
|
| 680 |
"plan": row["plan"],
|
| 681 |
"total_links": link_count,
|
| 682 |
-
"active_links": active,
|
| 683 |
"total_clicks": total_clicks,
|
| 684 |
"created_at": row["created_at"],
|
| 685 |
}
|
| 686 |
|
| 687 |
|
| 688 |
class URLShortenerService:
|
| 689 |
-
def __init__(self
|
| 690 |
-
self.
|
| 691 |
-
self.storage
|
| 692 |
-
logger.info("URLShortenerService initialized (
|
| 693 |
|
| 694 |
def close(self) -> None:
|
| 695 |
self.storage.close()
|
|
@@ -727,26 +759,15 @@ class URLShortenerService:
|
|
| 727 |
logger.info("Owner %s plan updated to %s", owner_id, plan)
|
| 728 |
return {"owner_id": owner_id, "plan": plan}
|
| 729 |
|
| 730 |
-
def shorten(
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
note: Optional[str] = None,
|
| 740 |
-
password: Optional[str] = None,
|
| 741 |
-
utm_source: Optional[str] = None,
|
| 742 |
-
utm_medium: Optional[str] = None,
|
| 743 |
-
utm_campaign: Optional[str] = None,
|
| 744 |
-
campaign_id: Optional[str] = None,
|
| 745 |
-
custom_domain: Optional[str] = None,
|
| 746 |
-
fallback_url: Optional[str] = None,
|
| 747 |
-
webhook_url: Optional[str] = None,
|
| 748 |
-
geo_targeting: Optional[Dict[str, Any]] = None,
|
| 749 |
-
) -> ShortLink:
|
| 750 |
plan_limits = self._get_owner_plan(owner_id)
|
| 751 |
|
| 752 |
if custom_alias is not None:
|
|
@@ -811,7 +832,7 @@ class URLShortenerService:
|
|
| 811 |
if fallback_url:
|
| 812 |
validate_url(fallback_url)
|
| 813 |
|
| 814 |
-
with self.storage.
|
| 815 |
code = custom_alias
|
| 816 |
if code is not None:
|
| 817 |
if self.storage.code_exists(code):
|
|
@@ -861,7 +882,7 @@ class URLShortenerService:
|
|
| 861 |
|
| 862 |
def resolve(self, short_code: str, referrer: Optional[str] = None,
|
| 863 |
user_agent: Optional[str] = None, ip_address: Optional[str] = None) -> str:
|
| 864 |
-
with self.storage.
|
| 865 |
row = self.storage.get_link(short_code)
|
| 866 |
if row is None:
|
| 867 |
raise LinkNotFoundError(f"No link found for code '{short_code}'")
|
|
@@ -889,7 +910,6 @@ class URLShortenerService:
|
|
| 889 |
self.storage.record_click(short_code, referrer, user_agent, ip_address)
|
| 890 |
long_url = row["long_url"]
|
| 891 |
|
| 892 |
-
# Geo-targeting: override destination based on geo/device rules
|
| 893 |
geo_raw = row["geo_targeting"]
|
| 894 |
if isinstance(geo_raw, str) and geo_raw:
|
| 895 |
try:
|
|
@@ -910,7 +930,6 @@ class URLShortenerService:
|
|
| 910 |
new_qs = "&".join(f"{k}={v}" for k, v in existing.items())
|
| 911 |
long_url = parsed._replace(query=new_qs).geturl()
|
| 912 |
|
| 913 |
-
# Fire webhook asynchronously outside the lock
|
| 914 |
webhook = row["webhook_url"]
|
| 915 |
if webhook:
|
| 916 |
try:
|
|
@@ -923,7 +942,7 @@ class URLShortenerService:
|
|
| 923 |
|
| 924 |
def resolve_with_password(self, short_code: str, password: str, referrer: Optional[str] = None,
|
| 925 |
user_agent: Optional[str] = None, ip_address: Optional[str] = None) -> str:
|
| 926 |
-
with self.storage.
|
| 927 |
row = self.storage.get_link(short_code)
|
| 928 |
if row is None:
|
| 929 |
raise LinkNotFoundError(f"No link found for code '{short_code}'")
|
|
@@ -944,7 +963,7 @@ class URLShortenerService:
|
|
| 944 |
def list_links(self, owner_id: str) -> List[ShortLink]:
|
| 945 |
return [self._row_to_link(r) for r in self.storage.list_links_for_owner(owner_id)]
|
| 946 |
|
| 947 |
-
def update_link(self, short_code: str, owner_id: str, **updates
|
| 948 |
row = self.storage.get_link(short_code)
|
| 949 |
if row is None:
|
| 950 |
raise LinkNotFoundError(f"No link found for code '{short_code}'")
|
|
@@ -1048,12 +1067,15 @@ class URLShortenerService:
|
|
| 1048 |
raise LinkNotFoundError(f"No link found for code '{short_code}'")
|
| 1049 |
if row["owner_id"] != owner_id:
|
| 1050 |
raise AuthorizationError("You do not own this link")
|
| 1051 |
-
|
|
|
|
|
|
|
|
|
|
| 1052 |
"SELECT id, referrer, user_agent, ip_address, browser, device, os, clicked_at "
|
| 1053 |
-
"FROM url_shortener_clicks WHERE short_code =
|
| 1054 |
(short_code, limit),
|
| 1055 |
-
)
|
| 1056 |
-
return
|
| 1057 |
|
| 1058 |
def get_qr_code(self, short_code: str) -> Dict[str, str]:
|
| 1059 |
row = self.storage.get_link(short_code)
|
|
@@ -1170,8 +1192,8 @@ class URLShortenerService:
|
|
| 1170 |
result["error"] = str(exc)
|
| 1171 |
return result
|
| 1172 |
|
| 1173 |
-
def _row_to_link(self, row:
|
| 1174 |
-
gt_raw = row
|
| 1175 |
geo = json.loads(gt_raw) if isinstance(gt_raw, str) and gt_raw else None
|
| 1176 |
return ShortLink(
|
| 1177 |
short_code=row["short_code"],
|
|
@@ -1182,15 +1204,15 @@ class URLShortenerService:
|
|
| 1182 |
max_clicks=row["max_clicks"],
|
| 1183 |
click_count=row["click_count"],
|
| 1184 |
is_active=bool(row["is_active"]),
|
| 1185 |
-
tags=row["tags"].split(",") if row
|
| 1186 |
-
note=row
|
| 1187 |
-
password_hash=row
|
| 1188 |
-
utm_source=row
|
| 1189 |
-
utm_medium=row
|
| 1190 |
-
utm_campaign=row
|
| 1191 |
-
campaign_id=row
|
| 1192 |
-
custom_domain=row
|
| 1193 |
-
fallback_url=row
|
| 1194 |
-
webhook_url=row
|
| 1195 |
geo_targeting=geo,
|
| 1196 |
)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
import csv
|
| 5 |
import hashlib
|
| 6 |
import hmac
|
|
|
|
| 10 |
import os
|
| 11 |
import secrets
|
| 12 |
import socket
|
|
|
|
| 13 |
import string
|
| 14 |
import threading
|
| 15 |
import unicodedata
|
|
|
|
| 16 |
from dataclasses import dataclass
|
| 17 |
from datetime import datetime, timedelta, timezone
|
| 18 |
from typing import Any, Dict, List, Optional
|
|
|
|
| 20 |
|
| 21 |
from app.config import get_settings
|
| 22 |
from app.core.logger import get_logger
|
| 23 |
+
from app.core.tidb_manager import TiDBManager, get_tidb_manager
|
| 24 |
|
| 25 |
logger = get_logger(__name__)
|
| 26 |
|
|
|
|
| 278 |
ua_lower = (user_agent or "").lower()
|
| 279 |
is_mobile = "mobile" in ua_lower or "android" in ua_lower or "iphone" in ua_lower
|
| 280 |
is_tablet = "tablet" in ua_lower or "ipad" in ua_lower
|
|
|
|
| 281 |
if is_tablet and "tablet" in devices:
|
| 282 |
return devices["tablet"]
|
| 283 |
if is_mobile and "mobile" in devices:
|
| 284 |
return devices["mobile"]
|
| 285 |
if not (is_mobile or is_tablet) and "desktop" in devices:
|
| 286 |
return devices["desktop"]
|
|
|
|
|
|
|
| 287 |
return default_url
|
| 288 |
|
| 289 |
|
|
|
|
| 305 |
client.post(webhook_url, json=payload)
|
| 306 |
except ImportError:
|
| 307 |
import urllib.request
|
| 308 |
+
data = json.dumps(payload).encode()
|
|
|
|
| 309 |
req = urllib.request.Request(webhook_url, data=data,
|
| 310 |
headers={"Content-Type": "application/json"},
|
| 311 |
method="POST")
|
| 312 |
urllib.request.urlopen(req, timeout=5)
|
| 313 |
|
| 314 |
|
| 315 |
+
# ---------------------------------------------------------------------------
|
| 316 |
+
# TiDB-backed Storage
|
| 317 |
+
# ---------------------------------------------------------------------------
|
| 318 |
+
|
| 319 |
+
US_TABLES: dict[str, str] = {
|
| 320 |
+
"url_shortener_owners": """
|
| 321 |
+
CREATE TABLE IF NOT EXISTS url_shortener_owners (
|
| 322 |
+
owner_id VARCHAR(64) PRIMARY KEY,
|
| 323 |
+
name VARCHAR(255) NOT NULL,
|
| 324 |
+
api_key_hash VARCHAR(128) NOT NULL,
|
| 325 |
+
plan VARCHAR(32) NOT NULL DEFAULT 'free',
|
| 326 |
+
created_at VARCHAR(64) NOT NULL,
|
| 327 |
+
UNIQUE KEY uk_uso_api_key_hash (api_key_hash)
|
| 328 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 329 |
+
""",
|
| 330 |
+
"url_shortener_links": """
|
| 331 |
+
CREATE TABLE IF NOT EXISTS url_shortener_links (
|
| 332 |
+
short_code VARCHAR(32) PRIMARY KEY,
|
| 333 |
+
long_url TEXT NOT NULL,
|
| 334 |
+
owner_id VARCHAR(64) NOT NULL,
|
| 335 |
+
created_at VARCHAR(64) NOT NULL,
|
| 336 |
+
expires_at VARCHAR(64),
|
| 337 |
+
max_clicks INT,
|
| 338 |
+
click_count INT NOT NULL DEFAULT 0,
|
| 339 |
+
last_accessed_at VARCHAR(64),
|
| 340 |
+
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
| 341 |
+
tags VARCHAR(512),
|
| 342 |
+
note TEXT,
|
| 343 |
+
password_hash VARCHAR(128),
|
| 344 |
+
utm_source VARCHAR(512),
|
| 345 |
+
utm_medium VARCHAR(512),
|
| 346 |
+
utm_campaign VARCHAR(512),
|
| 347 |
+
campaign_id VARCHAR(64),
|
| 348 |
+
custom_domain VARCHAR(255),
|
| 349 |
+
fallback_url TEXT,
|
| 350 |
+
webhook_url TEXT,
|
| 351 |
+
geo_targeting TEXT,
|
| 352 |
+
INDEX idx_usl_owner (owner_id),
|
| 353 |
+
INDEX idx_usl_campaign (campaign_id),
|
| 354 |
+
CONSTRAINT fk_usl_owner FOREIGN KEY (owner_id) REFERENCES url_shortener_owners(owner_id)
|
| 355 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 356 |
+
""",
|
| 357 |
+
"url_shortener_clicks": """
|
| 358 |
+
CREATE TABLE IF NOT EXISTS url_shortener_clicks (
|
| 359 |
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
| 360 |
+
short_code VARCHAR(32) NOT NULL,
|
| 361 |
+
referrer TEXT,
|
| 362 |
+
user_agent TEXT,
|
| 363 |
+
ip_address VARCHAR(45),
|
| 364 |
+
country VARCHAR(64),
|
| 365 |
+
browser VARCHAR(64),
|
| 366 |
+
device VARCHAR(64),
|
| 367 |
+
os VARCHAR(64),
|
| 368 |
+
clicked_at VARCHAR(64) NOT NULL,
|
| 369 |
+
INDEX idx_usc_code (short_code),
|
| 370 |
+
INDEX idx_usc_clicked_at (clicked_at),
|
| 371 |
+
CONSTRAINT fk_usc_link FOREIGN KEY (short_code) REFERENCES url_shortener_links(short_code)
|
| 372 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 373 |
+
""",
|
| 374 |
+
"url_shortener_audit_log": """
|
| 375 |
+
CREATE TABLE IF NOT EXISTS url_shortener_audit_log (
|
| 376 |
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
| 377 |
+
timestamp VARCHAR(64) NOT NULL,
|
| 378 |
+
owner_id VARCHAR(64),
|
| 379 |
+
action VARCHAR(64) NOT NULL,
|
| 380 |
+
short_code VARCHAR(32),
|
| 381 |
+
detail TEXT
|
| 382 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 383 |
+
""",
|
| 384 |
+
"url_shortener_campaigns": """
|
| 385 |
+
CREATE TABLE IF NOT EXISTS url_shortener_campaigns (
|
| 386 |
+
campaign_id VARCHAR(64) PRIMARY KEY,
|
| 387 |
+
owner_id VARCHAR(64) NOT NULL,
|
| 388 |
+
name VARCHAR(255) NOT NULL,
|
| 389 |
+
description TEXT,
|
| 390 |
+
created_at VARCHAR(64) NOT NULL,
|
| 391 |
+
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
| 392 |
+
INDEX idx_uscamp_owner (owner_id),
|
| 393 |
+
CONSTRAINT fk_uscamp_owner FOREIGN KEY (owner_id) REFERENCES url_shortener_owners(owner_id)
|
| 394 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 395 |
+
""",
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _run_async(coro):
|
| 400 |
+
try:
|
| 401 |
+
loop = asyncio.get_running_loop()
|
| 402 |
+
if loop.is_running():
|
| 403 |
+
import concurrent.futures
|
| 404 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1):
|
| 405 |
+
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
| 406 |
+
return future.result()
|
| 407 |
+
except RuntimeError:
|
| 408 |
+
pass
|
| 409 |
+
return asyncio.run(coro)
|
| 410 |
+
|
| 411 |
+
|
| 412 |
class Storage:
|
| 413 |
+
def __init__(self):
|
|
|
|
| 414 |
self._lock = threading.RLock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
|
| 416 |
+
def _get_mgr(self) -> TiDBManager:
|
| 417 |
+
mgr = get_tidb_manager()
|
| 418 |
+
if mgr is None:
|
| 419 |
+
raise RuntimeError("TiDB manager not initialized")
|
| 420 |
+
return mgr
|
| 421 |
+
|
| 422 |
+
def _init_schema(self):
|
| 423 |
+
mgr = self._get_mgr()
|
| 424 |
+
_run_async(mgr.ensure_tables(US_TABLES))
|
| 425 |
+
|
| 426 |
+
def close(self):
|
| 427 |
+
pass
|
| 428 |
+
|
| 429 |
+
# ---------- Owners ----------
|
| 430 |
+
|
| 431 |
+
def create_owner(self, owner_id: str, name: str, api_key_hash: str, plan: str = "free"):
|
| 432 |
+
with self._lock:
|
| 433 |
+
_run_async(self._get_mgr().execute(
|
| 434 |
"INSERT INTO url_shortener_owners (owner_id, name, api_key_hash, plan, created_at) "
|
| 435 |
+
"VALUES (%s, %s, %s, %s, %s)",
|
| 436 |
(owner_id, name, api_key_hash, plan, _now_iso()),
|
| 437 |
+
))
|
| 438 |
|
| 439 |
+
def get_owner_by_key_hash(self, api_key_hash: str) -> Optional[dict]:
|
| 440 |
+
return _run_async(self._get_mgr().fetchone_all(
|
| 441 |
+
"SELECT * FROM url_shortener_owners WHERE api_key_hash = %s",
|
| 442 |
+
(api_key_hash,),
|
| 443 |
+
))
|
| 444 |
|
| 445 |
+
def get_owner(self, owner_id: str) -> Optional[dict]:
|
| 446 |
+
return _run_async(self._get_mgr().fetchone_all(
|
| 447 |
+
"SELECT * FROM url_shortener_owners WHERE owner_id = %s",
|
| 448 |
+
(owner_id,),
|
| 449 |
+
))
|
| 450 |
|
| 451 |
+
def update_owner_plan(self, owner_id: str, plan: str):
|
| 452 |
+
with self._lock:
|
| 453 |
+
_run_async(self._get_mgr().execute(
|
| 454 |
+
"UPDATE url_shortener_owners SET plan = %s WHERE owner_id = %s",
|
| 455 |
+
(plan, owner_id),
|
| 456 |
+
))
|
| 457 |
+
|
| 458 |
+
# ---------- Links ----------
|
| 459 |
|
| 460 |
def code_exists(self, short_code: str) -> bool:
|
| 461 |
+
row = _run_async(self._get_mgr().fetchone_all(
|
| 462 |
+
"SELECT 1 AS chk FROM url_shortener_links WHERE short_code = %s",
|
| 463 |
+
(short_code,),
|
| 464 |
+
))
|
| 465 |
+
return row is not None
|
| 466 |
|
| 467 |
+
def insert_link(self, **kwargs):
|
| 468 |
+
with self._lock:
|
| 469 |
+
_run_async(self._get_mgr().insert_and_get_id("""
|
| 470 |
INSERT INTO url_shortener_links
|
| 471 |
(short_code, long_url, owner_id, created_at, expires_at,
|
| 472 |
max_clicks, click_count, last_accessed_at, is_active, tags, note,
|
| 473 |
password_hash, utm_source, utm_medium, utm_campaign,
|
| 474 |
campaign_id, custom_domain, fallback_url, webhook_url, geo_targeting)
|
| 475 |
VALUES
|
| 476 |
+
(%(short_code)s, %(long_url)s, %(owner_id)s, %(created_at)s, %(expires_at)s,
|
| 477 |
+
%(max_clicks)s, 0, NULL, 1, %(tags)s, %(note)s,
|
| 478 |
+
%(password_hash)s, %(utm_source)s, %(utm_medium)s, %(utm_campaign)s,
|
| 479 |
+
%(campaign_id)s, %(custom_domain)s, %(fallback_url)s, %(webhook_url)s,
|
| 480 |
+
%(geo_targeting)s)
|
| 481 |
+
""", kwargs))
|
| 482 |
+
|
| 483 |
+
def update_link(self, short_code: str, **updates):
|
| 484 |
+
with self._lock:
|
| 485 |
+
sets = ", ".join(f"{k} = %s" for k in updates)
|
| 486 |
vals = list(updates.values()) + [short_code]
|
| 487 |
+
_run_async(self._get_mgr().execute(
|
| 488 |
+
f"UPDATE url_shortener_links SET {sets} WHERE short_code = %s",
|
| 489 |
+
tuple(vals),
|
| 490 |
+
))
|
| 491 |
+
|
| 492 |
+
def get_link(self, short_code: str) -> Optional[dict]:
|
| 493 |
+
return _run_async(self._get_mgr().fetchone_all(
|
| 494 |
+
"SELECT * FROM url_shortener_links WHERE short_code = %s",
|
| 495 |
+
(short_code,),
|
| 496 |
+
))
|
| 497 |
+
|
| 498 |
+
def list_links_for_owner(self, owner_id: str) -> List[dict]:
|
| 499 |
+
return _run_async(self._get_mgr().fetchall_all(
|
| 500 |
+
"SELECT * FROM url_shortener_links WHERE owner_id = %s ORDER BY created_at DESC",
|
| 501 |
(owner_id,),
|
| 502 |
+
))
|
| 503 |
|
| 504 |
+
def deactivate_link(self, short_code: str):
|
| 505 |
+
with self._lock:
|
| 506 |
+
_run_async(self._get_mgr().execute(
|
| 507 |
+
"UPDATE url_shortener_links SET is_active = 0 WHERE short_code = %s",
|
| 508 |
+
(short_code,),
|
| 509 |
+
))
|
| 510 |
|
| 511 |
def record_click(self, short_code: str, referrer: Optional[str] = None,
|
| 512 |
+
user_agent: Optional[str] = None, ip_address: Optional[str] = None):
|
| 513 |
now = _now_iso()
|
| 514 |
parsed = _parse_user_agent(user_agent or "")
|
| 515 |
+
with self._lock:
|
| 516 |
+
_run_async(self._get_mgr().execute(
|
| 517 |
"INSERT INTO url_shortener_clicks "
|
| 518 |
"(short_code, referrer, user_agent, ip_address, country, browser, device, os, clicked_at) "
|
| 519 |
+
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
| 520 |
(short_code, referrer, user_agent, ip_address, None,
|
| 521 |
parsed["browser"], parsed["device"], parsed["os"], now),
|
| 522 |
+
))
|
| 523 |
+
_run_async(self._get_mgr().execute(
|
| 524 |
+
"UPDATE url_shortener_links SET click_count = click_count + 1, last_accessed_at = %s "
|
| 525 |
+
"WHERE short_code = %s",
|
| 526 |
(now, short_code),
|
| 527 |
+
))
|
| 528 |
|
| 529 |
def get_click_analytics(self, short_code: str) -> Dict[str, Any]:
|
| 530 |
+
mgr = self._get_mgr()
|
| 531 |
+
total = _run_async(mgr.fetchone_all(
|
| 532 |
+
"SELECT COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s",
|
| 533 |
+
(short_code,),
|
| 534 |
+
))
|
| 535 |
+
total_val = total["cnt"] if total else 0
|
| 536 |
+
browsers_rows = _run_async(mgr.fetchall_all(
|
| 537 |
+
"SELECT browser, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
|
| 538 |
+
"AND browser IS NOT NULL GROUP BY browser ORDER BY cnt DESC",
|
| 539 |
+
(short_code,),
|
| 540 |
+
))
|
| 541 |
+
devices_rows = _run_async(mgr.fetchall_all(
|
| 542 |
+
"SELECT device, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
|
| 543 |
+
"AND device IS NOT NULL GROUP BY device ORDER BY cnt DESC",
|
| 544 |
+
(short_code,),
|
| 545 |
+
))
|
| 546 |
+
referrers_rows = _run_async(mgr.fetchall_all(
|
| 547 |
"SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS ref, COUNT(*) AS n "
|
| 548 |
+
"FROM url_shortener_clicks WHERE short_code = %s "
|
| 549 |
"GROUP BY ref ORDER BY n DESC LIMIT 10",
|
| 550 |
+
(short_code,),
|
| 551 |
+
))
|
| 552 |
+
os_rows = _run_async(mgr.fetchall_all(
|
| 553 |
+
"SELECT os, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
|
| 554 |
+
"AND os IS NOT NULL GROUP BY os ORDER BY cnt DESC",
|
| 555 |
+
(short_code,),
|
| 556 |
+
))
|
| 557 |
+
recent = _run_async(mgr.fetchall_all(
|
| 558 |
+
"SELECT clicked_at FROM url_shortener_clicks WHERE short_code = %s "
|
| 559 |
"ORDER BY clicked_at DESC LIMIT 50",
|
| 560 |
+
(short_code,),
|
| 561 |
+
))
|
| 562 |
return {
|
| 563 |
+
"total_clicks": total_val,
|
| 564 |
+
"browsers": {r["browser"]: r["cnt"] for r in browsers_rows},
|
| 565 |
+
"devices": {r["device"]: r["cnt"] for r in devices_rows},
|
| 566 |
+
"operating_systems": {r["os"]: r["cnt"] for r in os_rows},
|
| 567 |
+
"top_referrers": {r["ref"]: r["n"] for r in referrers_rows},
|
| 568 |
"recent_clicks": [r["clicked_at"] for r in recent],
|
| 569 |
}
|
| 570 |
|
| 571 |
def get_link_count_for_owner(self, owner_id: str) -> int:
|
| 572 |
+
row = _run_async(self._get_mgr().fetchone_all(
|
| 573 |
+
"SELECT COUNT(*) AS cnt FROM url_shortener_links WHERE owner_id = %s",
|
| 574 |
+
(owner_id,),
|
| 575 |
+
))
|
| 576 |
+
return row["cnt"] if row else 0
|
| 577 |
|
| 578 |
+
def audit(self, owner_id: Optional[str], action: str, short_code: Optional[str], detail: str = ""):
|
| 579 |
+
with self._lock:
|
| 580 |
+
_run_async(self._get_mgr().execute(
|
| 581 |
"INSERT INTO url_shortener_audit_log (timestamp, owner_id, action, short_code, detail) "
|
| 582 |
+
"VALUES (%s, %s, %s, %s, %s)",
|
| 583 |
(_now_iso(), owner_id, action, short_code, detail),
|
| 584 |
+
))
|
| 585 |
|
| 586 |
def export_links_csv(self, owner_id: str) -> str:
|
| 587 |
+
rows = _run_async(self._get_mgr().fetchall_all(
|
| 588 |
"SELECT short_code, long_url, created_at, click_count, is_active, tags, note "
|
| 589 |
+
"FROM url_shortener_links WHERE owner_id = %s ORDER BY created_at DESC",
|
| 590 |
+
(owner_id,),
|
| 591 |
+
))
|
| 592 |
buf = io.StringIO()
|
| 593 |
w = csv.writer(buf)
|
| 594 |
w.writerow(["short_code", "long_url", "created_at", "click_count", "is_active", "tags", "note"])
|
|
|
|
| 598 |
return buf.getvalue()
|
| 599 |
|
| 600 |
def export_clicks_csv(self, short_code: str) -> str:
|
| 601 |
+
rows = _run_async(self._get_mgr().fetchall_all(
|
| 602 |
"SELECT id, referrer, user_agent, ip_address, browser, device, os, clicked_at "
|
| 603 |
+
"FROM url_shortener_clicks WHERE short_code = %s ORDER BY clicked_at DESC",
|
| 604 |
+
(short_code,),
|
| 605 |
+
))
|
| 606 |
buf = io.StringIO()
|
| 607 |
w = csv.writer(buf)
|
| 608 |
w.writerow(["id", "referrer", "user_agent", "ip_address", "browser", "device", "os", "clicked_at"])
|
|
|
|
| 611 |
r["browser"], r["device"], r["os"], r["clicked_at"]])
|
| 612 |
return buf.getvalue()
|
| 613 |
|
| 614 |
+
def list_links_by_campaign(self, campaign_id: str) -> List[dict]:
|
| 615 |
+
return _run_async(self._get_mgr().fetchall_all(
|
| 616 |
+
"SELECT * FROM url_shortener_links WHERE campaign_id = %s ORDER BY created_at DESC",
|
| 617 |
(campaign_id,),
|
| 618 |
+
))
|
| 619 |
|
| 620 |
+
def create_campaign(self, campaign_id: str, owner_id: str, name: str, description: Optional[str] = None):
|
| 621 |
+
with self._lock:
|
| 622 |
+
_run_async(self._get_mgr().execute(
|
| 623 |
"INSERT INTO url_shortener_campaigns (campaign_id, owner_id, name, description, created_at) "
|
| 624 |
+
"VALUES (%s, %s, %s, %s, %s)",
|
| 625 |
(campaign_id, owner_id, name, description, _now_iso()),
|
| 626 |
+
))
|
| 627 |
|
| 628 |
+
def get_campaign(self, campaign_id: str) -> Optional[dict]:
|
| 629 |
+
return _run_async(self._get_mgr().fetchone_all(
|
| 630 |
+
"SELECT * FROM url_shortener_campaigns WHERE campaign_id = %s",
|
| 631 |
+
(campaign_id,),
|
| 632 |
+
))
|
| 633 |
|
| 634 |
+
def list_campaigns_for_owner(self, owner_id: str) -> List[dict]:
|
| 635 |
+
return _run_async(self._get_mgr().fetchall_all(
|
| 636 |
+
"SELECT * FROM url_shortener_campaigns WHERE owner_id = %s ORDER BY created_at DESC",
|
| 637 |
(owner_id,),
|
| 638 |
+
))
|
| 639 |
|
| 640 |
+
def deactivate_campaign(self, campaign_id: str):
|
| 641 |
+
with self._lock:
|
| 642 |
+
_run_async(self._get_mgr().execute(
|
| 643 |
+
"UPDATE url_shortener_campaigns SET is_active = 0 WHERE campaign_id = %s",
|
| 644 |
(campaign_id,),
|
| 645 |
+
))
|
| 646 |
|
| 647 |
def get_campaign_link_count(self, campaign_id: str) -> int:
|
| 648 |
+
row = _run_async(self._get_mgr().fetchone_all(
|
| 649 |
+
"SELECT COUNT(*) AS cnt FROM url_shortener_links WHERE campaign_id = %s",
|
| 650 |
+
(campaign_id,),
|
| 651 |
+
))
|
| 652 |
+
return row["cnt"] if row else 0
|
| 653 |
|
| 654 |
def get_campaign_total_clicks(self, campaign_id: str) -> int:
|
| 655 |
+
row = _run_async(self._get_mgr().fetchone_all(
|
| 656 |
+
"SELECT COALESCE(SUM(click_count), 0) AS cnt FROM url_shortener_links WHERE campaign_id = %s",
|
| 657 |
(campaign_id,),
|
| 658 |
+
))
|
| 659 |
+
return row["cnt"] if row else 0
|
| 660 |
|
| 661 |
def get_campaign_analytics(self, campaign_id: str) -> Dict[str, Any]:
|
| 662 |
+
codes = _run_async(self._get_mgr().fetchall_all(
|
| 663 |
+
"SELECT short_code FROM url_shortener_links WHERE campaign_id = %s",
|
| 664 |
+
(campaign_id,),
|
| 665 |
+
))
|
| 666 |
total = 0
|
| 667 |
browsers: Dict[str, int] = {}
|
| 668 |
devices: Dict[str, int] = {}
|
| 669 |
os_data: Dict[str, int] = {}
|
| 670 |
+
mgr = self._get_mgr()
|
| 671 |
+
for row in codes:
|
| 672 |
+
code = row["short_code"]
|
| 673 |
+
cnt_row = _run_async(mgr.fetchone_all(
|
| 674 |
+
"SELECT COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s",
|
| 675 |
+
(code,),
|
| 676 |
+
))
|
| 677 |
+
total += cnt_row["cnt"] if cnt_row else 0
|
| 678 |
+
for b in _run_async(mgr.fetchall_all(
|
| 679 |
+
"SELECT browser, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
|
| 680 |
+
"AND browser IS NOT NULL GROUP BY browser", (code,),
|
| 681 |
+
)):
|
| 682 |
+
browsers[b["browser"]] = browsers.get(b["browser"], 0) + b["cnt"]
|
| 683 |
+
for d in _run_async(mgr.fetchall_all(
|
| 684 |
+
"SELECT device, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
|
| 685 |
+
"AND device IS NOT NULL GROUP BY device", (code,),
|
| 686 |
+
)):
|
| 687 |
+
devices[d["device"]] = devices.get(d["device"], 0) + d["cnt"]
|
| 688 |
+
for o in _run_async(mgr.fetchall_all(
|
| 689 |
+
"SELECT os, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
|
| 690 |
+
"AND os IS NOT NULL GROUP BY os", (code,),
|
| 691 |
+
)):
|
| 692 |
+
os_data[o["os"]] = os_data.get(o["os"], 0) + o["cnt"]
|
| 693 |
return {"total_clicks": total, "browsers": browsers, "devices": devices, "operating_systems": os_data}
|
| 694 |
|
| 695 |
def owner_summary(self, owner_id: str) -> Dict[str, Any]:
|
|
|
|
| 697 |
if row is None:
|
| 698 |
return {}
|
| 699 |
link_count = self.get_link_count_for_owner(owner_id)
|
| 700 |
+
total_clicks_row = _run_async(self._get_mgr().fetchone_all(
|
| 701 |
+
"SELECT COALESCE(SUM(click_count), 0) AS cnt FROM url_shortener_links WHERE owner_id = %s",
|
| 702 |
+
(owner_id,),
|
| 703 |
+
))
|
| 704 |
+
total_clicks = total_clicks_row["cnt"] if total_clicks_row else 0
|
| 705 |
+
active = _run_async(self._get_mgr().fetchone_all(
|
| 706 |
+
"SELECT COUNT(*) AS cnt FROM url_shortener_links WHERE owner_id = %s AND is_active = 1",
|
| 707 |
+
(owner_id,),
|
| 708 |
+
))
|
| 709 |
return {
|
| 710 |
"owner_id": owner_id,
|
| 711 |
"name": row["name"],
|
| 712 |
"plan": row["plan"],
|
| 713 |
"total_links": link_count,
|
| 714 |
+
"active_links": active["cnt"] if active else 0,
|
| 715 |
"total_clicks": total_clicks,
|
| 716 |
"created_at": row["created_at"],
|
| 717 |
}
|
| 718 |
|
| 719 |
|
| 720 |
class URLShortenerService:
|
| 721 |
+
def __init__(self):
|
| 722 |
+
self.storage = Storage()
|
| 723 |
+
self.storage._init_schema()
|
| 724 |
+
logger.info("URLShortenerService initialized (TiDB)")
|
| 725 |
|
| 726 |
def close(self) -> None:
|
| 727 |
self.storage.close()
|
|
|
|
| 759 |
logger.info("Owner %s plan updated to %s", owner_id, plan)
|
| 760 |
return {"owner_id": owner_id, "plan": plan}
|
| 761 |
|
| 762 |
+
def shorten(self, long_url: str, owner_id: str, custom_alias: Optional[str] = None,
|
| 763 |
+
expires_in_days: Optional[int] = None, expires_at: Optional[datetime] = None,
|
| 764 |
+
max_clicks: Optional[int] = None, tags: Optional[List[str]] = None,
|
| 765 |
+
note: Optional[str] = None, password: Optional[str] = None,
|
| 766 |
+
utm_source: Optional[str] = None, utm_medium: Optional[str] = None,
|
| 767 |
+
utm_campaign: Optional[str] = None, campaign_id: Optional[str] = None,
|
| 768 |
+
custom_domain: Optional[str] = None, fallback_url: Optional[str] = None,
|
| 769 |
+
webhook_url: Optional[str] = None,
|
| 770 |
+
geo_targeting: Optional[Dict[str, Any]] = None) -> ShortLink:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 771 |
plan_limits = self._get_owner_plan(owner_id)
|
| 772 |
|
| 773 |
if custom_alias is not None:
|
|
|
|
| 832 |
if fallback_url:
|
| 833 |
validate_url(fallback_url)
|
| 834 |
|
| 835 |
+
with self.storage._lock:
|
| 836 |
code = custom_alias
|
| 837 |
if code is not None:
|
| 838 |
if self.storage.code_exists(code):
|
|
|
|
| 882 |
|
| 883 |
def resolve(self, short_code: str, referrer: Optional[str] = None,
|
| 884 |
user_agent: Optional[str] = None, ip_address: Optional[str] = None) -> str:
|
| 885 |
+
with self.storage._lock:
|
| 886 |
row = self.storage.get_link(short_code)
|
| 887 |
if row is None:
|
| 888 |
raise LinkNotFoundError(f"No link found for code '{short_code}'")
|
|
|
|
| 910 |
self.storage.record_click(short_code, referrer, user_agent, ip_address)
|
| 911 |
long_url = row["long_url"]
|
| 912 |
|
|
|
|
| 913 |
geo_raw = row["geo_targeting"]
|
| 914 |
if isinstance(geo_raw, str) and geo_raw:
|
| 915 |
try:
|
|
|
|
| 930 |
new_qs = "&".join(f"{k}={v}" for k, v in existing.items())
|
| 931 |
long_url = parsed._replace(query=new_qs).geturl()
|
| 932 |
|
|
|
|
| 933 |
webhook = row["webhook_url"]
|
| 934 |
if webhook:
|
| 935 |
try:
|
|
|
|
| 942 |
|
| 943 |
def resolve_with_password(self, short_code: str, password: str, referrer: Optional[str] = None,
|
| 944 |
user_agent: Optional[str] = None, ip_address: Optional[str] = None) -> str:
|
| 945 |
+
with self.storage._lock:
|
| 946 |
row = self.storage.get_link(short_code)
|
| 947 |
if row is None:
|
| 948 |
raise LinkNotFoundError(f"No link found for code '{short_code}'")
|
|
|
|
| 963 |
def list_links(self, owner_id: str) -> List[ShortLink]:
|
| 964 |
return [self._row_to_link(r) for r in self.storage.list_links_for_owner(owner_id)]
|
| 965 |
|
| 966 |
+
def update_link(self, short_code: str, owner_id: str, **updates) -> ShortLink:
|
| 967 |
row = self.storage.get_link(short_code)
|
| 968 |
if row is None:
|
| 969 |
raise LinkNotFoundError(f"No link found for code '{short_code}'")
|
|
|
|
| 1067 |
raise LinkNotFoundError(f"No link found for code '{short_code}'")
|
| 1068 |
if row["owner_id"] != owner_id:
|
| 1069 |
raise AuthorizationError("You do not own this link")
|
| 1070 |
+
mgr = get_tidb_manager()
|
| 1071 |
+
if mgr is None:
|
| 1072 |
+
return []
|
| 1073 |
+
rows = _run_async(mgr.fetchall_all(
|
| 1074 |
"SELECT id, referrer, user_agent, ip_address, browser, device, os, clicked_at "
|
| 1075 |
+
"FROM url_shortener_clicks WHERE short_code = %s ORDER BY clicked_at DESC LIMIT %s",
|
| 1076 |
(short_code, limit),
|
| 1077 |
+
))
|
| 1078 |
+
return list(rows)
|
| 1079 |
|
| 1080 |
def get_qr_code(self, short_code: str) -> Dict[str, str]:
|
| 1081 |
row = self.storage.get_link(short_code)
|
|
|
|
| 1192 |
result["error"] = str(exc)
|
| 1193 |
return result
|
| 1194 |
|
| 1195 |
+
def _row_to_link(self, row: dict) -> ShortLink:
|
| 1196 |
+
gt_raw = row.get("geo_targeting")
|
| 1197 |
geo = json.loads(gt_raw) if isinstance(gt_raw, str) and gt_raw else None
|
| 1198 |
return ShortLink(
|
| 1199 |
short_code=row["short_code"],
|
|
|
|
| 1204 |
max_clicks=row["max_clicks"],
|
| 1205 |
click_count=row["click_count"],
|
| 1206 |
is_active=bool(row["is_active"]),
|
| 1207 |
+
tags=row["tags"].split(",") if row.get("tags") else [],
|
| 1208 |
+
note=row.get("note"),
|
| 1209 |
+
password_hash=row.get("password_hash"),
|
| 1210 |
+
utm_source=row.get("utm_source"),
|
| 1211 |
+
utm_medium=row.get("utm_medium"),
|
| 1212 |
+
utm_campaign=row.get("utm_campaign"),
|
| 1213 |
+
campaign_id=row.get("campaign_id"),
|
| 1214 |
+
custom_domain=row.get("custom_domain"),
|
| 1215 |
+
fallback_url=row.get("fallback_url"),
|
| 1216 |
+
webhook_url=row.get("webhook_url"),
|
| 1217 |
geo_targeting=geo,
|
| 1218 |
)
|
app/services/vector_store_service.py
CHANGED
|
@@ -11,12 +11,10 @@ from datetime import datetime, timezone
|
|
| 11 |
from typing import Any, Dict, List, Optional, Tuple
|
| 12 |
|
| 13 |
import zvec
|
| 14 |
-
from sqlalchemy import select
|
| 15 |
|
| 16 |
from app.config import get_settings
|
| 17 |
from app.core.logger import get_logger
|
| 18 |
-
from app.core.
|
| 19 |
-
from app.core.vector_store.models import VectorStoreIndex
|
| 20 |
from app.services.chunking_service import chunk_text_async
|
| 21 |
from app.services.embeddings_service import EmbeddingService
|
| 22 |
|
|
@@ -64,58 +62,71 @@ class VectorStoreService:
|
|
| 64 |
def _get_collection(self, store_id: str) -> Optional["zvec.Collection"]:
|
| 65 |
return self._collections.get(store_id)
|
| 66 |
|
| 67 |
-
# ---
|
| 68 |
|
| 69 |
async def init_db(self) -> None:
|
| 70 |
from app.core.vector_store.deps import init_vector_store_db
|
| 71 |
await init_vector_store_db()
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
)
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
async def _persist_store(self, record: VectorStoreRecord) -> None:
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
async def _remove_persisted_store(self, store_id: str) -> None:
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
| 119 |
|
| 120 |
# --- Synchronous helpers (run in thread pool) ---
|
| 121 |
|
|
|
|
| 11 |
from typing import Any, Dict, List, Optional, Tuple
|
| 12 |
|
| 13 |
import zvec
|
|
|
|
| 14 |
|
| 15 |
from app.config import get_settings
|
| 16 |
from app.core.logger import get_logger
|
| 17 |
+
from app.core.tidb_manager import get_tidb_manager
|
|
|
|
| 18 |
from app.services.chunking_service import chunk_text_async
|
| 19 |
from app.services.embeddings_service import EmbeddingService
|
| 20 |
|
|
|
|
| 62 |
def _get_collection(self, store_id: str) -> Optional["zvec.Collection"]:
|
| 63 |
return self._collections.get(store_id)
|
| 64 |
|
| 65 |
+
# --- TiDB persistence ---
|
| 66 |
|
| 67 |
async def init_db(self) -> None:
|
| 68 |
from app.core.vector_store.deps import init_vector_store_db
|
| 69 |
await init_vector_store_db()
|
| 70 |
+
mgr = get_tidb_manager()
|
| 71 |
+
if mgr is None:
|
| 72 |
+
logger.warning("TiDB manager not available, vector store metadata not loaded")
|
| 73 |
+
return
|
| 74 |
+
rows = await mgr.fetchall_all(
|
| 75 |
+
"SELECT * FROM vector_store_index ORDER BY created_at ASC"
|
| 76 |
+
)
|
| 77 |
+
for row in rows:
|
| 78 |
+
record = VectorStoreRecord(
|
| 79 |
+
store_id=row["store_id"],
|
| 80 |
+
name=row["name"],
|
| 81 |
+
path=row["path"],
|
| 82 |
+
description=row.get("description", ""),
|
| 83 |
+
metadata=json.loads(row.get("metadata_json", "{}") or "{}"),
|
| 84 |
+
created_at=row["created_at"],
|
| 85 |
+
)
|
| 86 |
+
self._stores[record.store_id] = record
|
| 87 |
+
store_path = row["path"]
|
| 88 |
+
if os.path.exists(os.path.join(store_path, "__zvec_meta")):
|
| 89 |
+
try:
|
| 90 |
+
col = zvec.open(store_path)
|
| 91 |
+
if col is not None:
|
| 92 |
+
self._collections[record.store_id] = col
|
| 93 |
+
except Exception as exc:
|
| 94 |
+
logger.warning("Could not open collection %s: %s", record.store_id, exc)
|
| 95 |
|
| 96 |
async def _persist_store(self, record: VectorStoreRecord) -> None:
|
| 97 |
+
mgr = get_tidb_manager()
|
| 98 |
+
if mgr is None:
|
| 99 |
+
logger.error("TiDB manager not available, cannot persist store")
|
| 100 |
+
return
|
| 101 |
+
existing = await mgr.fetchone(
|
| 102 |
+
"SELECT store_id FROM vector_store_index WHERE store_id = %s",
|
| 103 |
+
(record.store_id,),
|
| 104 |
+
)
|
| 105 |
+
if existing:
|
| 106 |
+
await mgr.execute(
|
| 107 |
+
"UPDATE vector_store_index SET name = %s, description = %s, "
|
| 108 |
+
"metadata_json = %s WHERE store_id = %s",
|
| 109 |
+
(record.name, record.description, json.dumps(record.metadata), record.store_id),
|
| 110 |
+
)
|
| 111 |
+
else:
|
| 112 |
+
await mgr.execute(
|
| 113 |
+
"INSERT INTO vector_store_index (store_id, name, path, description, metadata_json, created_at) "
|
| 114 |
+
"VALUES (%s, %s, %s, %s, %s, %s)",
|
| 115 |
+
(
|
| 116 |
+
record.store_id, record.name, record.path,
|
| 117 |
+
record.description, json.dumps(record.metadata),
|
| 118 |
+
record.created_at,
|
| 119 |
+
),
|
| 120 |
+
)
|
| 121 |
|
| 122 |
async def _remove_persisted_store(self, store_id: str) -> None:
|
| 123 |
+
mgr = get_tidb_manager()
|
| 124 |
+
if mgr is None:
|
| 125 |
+
return
|
| 126 |
+
await mgr.execute(
|
| 127 |
+
"DELETE FROM vector_store_index WHERE store_id = %s",
|
| 128 |
+
(store_id,),
|
| 129 |
+
)
|
| 130 |
|
| 131 |
# --- Synchronous helpers (run in thread pool) ---
|
| 132 |
|
app/services/verify_service.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import phonenumbers
|
| 4 |
-
from phonenumbers import
|
| 5 |
|
| 6 |
|
| 7 |
class PhoneVerificationResult:
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import phonenumbers
|
| 4 |
+
from phonenumbers import PhoneNumberType, carrier, geocoder
|
| 5 |
|
| 6 |
|
| 7 |
class PhoneVerificationResult:
|
pyproject.toml
CHANGED
|
@@ -36,6 +36,10 @@ dependencies = [
|
|
| 36 |
[project.optional-dependencies]
|
| 37 |
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
[tool.setuptools.packages.find]
|
| 40 |
where = ["."]
|
| 41 |
include = ["All API Collection*"]
|
|
|
|
| 36 |
[project.optional-dependencies]
|
| 37 |
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
|
| 38 |
|
| 39 |
+
[tool.pytest.ini_options]
|
| 40 |
+
asyncio_mode = "auto"
|
| 41 |
+
asyncio_default_fixture_loop_scope = "function"
|
| 42 |
+
|
| 43 |
[tool.setuptools.packages.find]
|
| 44 |
where = ["."]
|
| 45 |
include = ["All API Collection*"]
|
requirements.txt
CHANGED
|
@@ -36,8 +36,6 @@ email-validator>=2.1.0
|
|
| 36 |
slowapi>=0.1.9
|
| 37 |
|
| 38 |
# Async database drivers
|
| 39 |
-
aiosqlite>=0.20.0
|
| 40 |
-
sqlalchemy[asyncio]>=2.0.0
|
| 41 |
aiomysql>=0.3.2
|
| 42 |
asyncpg>=0.31.0
|
| 43 |
motor>=3.7.1
|
|
|
|
| 36 |
slowapi>=0.1.9
|
| 37 |
|
| 38 |
# Async database drivers
|
|
|
|
|
|
|
| 39 |
aiomysql>=0.3.2
|
| 40 |
asyncpg>=0.31.0
|
| 41 |
motor>=3.7.1
|