validops-east-1 commited on
Commit
5a01a63
·
1 Parent(s): e463de2

feat: add tidb

Browse files
.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 create_redis_client, close_redis
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
- _logger.info("Initializing authentication database...")
47
- await init_auth_db()
48
- _logger.info("Authentication database initialized")
49
-
50
- _logger.info("Initializing vector store database...")
51
- await _vector_store_service.init_db()
52
- _logger.info("Vector store database initialized with %d stores", len(_vector_store_service.list_stores()))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 # allow backup/restore during maintenance
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, get_temp_db_warning, require_application_id
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, warning):
35
  result = {"success": True, "data": data}
36
- if _settings.application_id:
37
- result["application_id"] = _settings.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[AsyncSession, Depends(get_db)],
47
  _: Annotated[bool, Depends(require_application_id)],
48
  ):
49
  user = await AuthService.register(db, schema)
50
- warning = get_temp_db_warning()
51
- return _build(AuthService.user_to_profile(user).model_dump(), warning)
52
 
53
 
54
  @router.post("/login", response_model=TokenResponse)
55
  async def login(
56
  schema: LoginSchema,
57
  request: Request,
58
- db: Annotated[AsyncSession, Depends(get_db)],
59
  _: Annotated[bool, Depends(require_application_id)],
60
  ):
61
  tokens = await AuthService.login(db, request, schema)
62
- warning = get_temp_db_warning()
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[AsyncSession, Depends(get_db)],
70
  _: Annotated[bool, Depends(require_application_id)],
71
  ):
72
  tokens = await AuthService.refresh(db, schema.refresh_token)
73
- warning = get_temp_db_warning()
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[AsyncSession, Depends(get_db)],
82
  _: Annotated[bool, Depends(require_application_id)],
83
  ):
84
  await AuthService.logout(db, current_user, schema.refresh_token)
85
- warning = get_temp_db_warning()
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[AsyncSession, Depends(get_db)],
93
  _: Annotated[bool, Depends(require_application_id)],
94
  ):
95
  await AuthService.logout_all(db, current_user)
96
- warning = get_temp_db_warning()
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[AsyncSession, Depends(get_db)],
104
  _: Annotated[bool, Depends(require_application_id)],
105
  ):
106
  await AuthService.forgot_password(db, schema)
107
- warning = get_temp_db_warning()
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[AsyncSession, Depends(get_db)],
115
  _: Annotated[bool, Depends(require_application_id)],
116
  ):
117
  await AuthService.reset_password(db, schema)
118
- warning = get_temp_db_warning()
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[AsyncSession, Depends(get_db)],
127
  _: Annotated[bool, Depends(require_application_id)],
128
  ):
129
  await AuthService.change_password(db, current_user, schema)
130
- warning = get_temp_db_warning()
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
- warning = get_temp_db_warning()
140
- return _build(AuthService.user_to_profile(current_user).model_dump(), warning)
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[AsyncSession, Depends(get_db)],
148
  _: Annotated[bool, Depends(require_application_id)],
149
  ):
150
  user = await AuthService.update_profile(db, current_user, schema)
151
- warning = get_temp_db_warning()
152
- return _build(AuthService.user_to_profile(user).model_dump(), warning)
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[AsyncSession, Depends(get_db)],
159
  _: Annotated[bool, Depends(require_application_id)],
160
  ):
161
  await AuthService.soft_delete(db, current_user)
162
- warning = get_temp_db_warning()
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
- warning = get_temp_db_warning()
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[AsyncSession, Depends(get_db)],
196
  _: Annotated[bool, Depends(require_application_id)],
197
  ):
198
  sessions = await AuthService.list_sessions(db, current_user)
199
- warning = get_temp_db_warning()
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[AsyncSession, Depends(get_db)],
208
  _: Annotated[bool, Depends(require_application_id)],
209
  ):
210
  await AuthService.revoke_session(db, current_user, session_id)
211
- warning = get_temp_db_warning()
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 get_converter_service, get_extraction_service, get_text_cleaner_service, require_auth
 
 
 
 
 
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 chat_completion, _stream_chat_completion
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, Field, 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
- 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 DatabaseQueryRequest, DatabaseQueryResponse, DatabaseValidateRequest, DatabaseValidateResponse
 
 
 
 
 
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 require_auth, get_embeddings_service
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 QRGeneratorService, QRGeneratorError
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 auth, batch, chat, code_executor, convert, csv_analysis, database, embeddings, qr_generator, reconcile, scraper, semantic_router, sql_validator, system, token_counter, token_generator, url_shortener, vector_stores, web_search, webhook_socket
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
 
 
 
 
 
 
 
 
 
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 APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
 
 
 
 
 
 
 
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
- database_url: str = "sqlite+aiosqlite:///data/auth.db"
 
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.models import (
4
- Base, Permission, Role, User, RefreshSession,
5
- user_roles, role_permissions,
6
- )
7
  from app.core.auth.schemas import (
8
- RegisterSchema, LoginSchema, TokenResponse, TokenRefreshSchema,
9
- UserProfile, UpdateProfileSchema, ChangePasswordSchema,
10
- ForgotPasswordSchema, ResetPasswordSchema, SessionOut,
11
- MessageResponse, UserSchemaResponse, UserSchemaField,
12
- )
13
- from app.core.auth.deps import (
14
- get_db, get_current_user, require_permissions,
15
- get_temp_db_warning, TempDatabaseWarning,
 
 
 
 
 
16
  )
17
 
18
  __all__ = [
19
- "Base", "Permission", "Role", "User", "RefreshSession",
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 typing import Annotated, AsyncGenerator, Optional
 
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 Base, Permission, Role, User
 
 
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
- def get_temp_db_warning() -> Optional[dict]:
49
- if _is_temp_db:
50
- return {"code": TempDatabaseWarning.code, "message": TempDatabaseWarning.message}
51
- return None
 
 
 
 
 
 
 
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- async def init_auth_db():
55
- async with engine.begin() as conn:
56
- await conn.run_sync(Base.metadata.create_all)
57
-
58
- async with AsyncSessionLocal() as session:
59
- result = await session.execute(select(Role).where(Role.name == "SuperAdmin"))
60
- if not result.scalars().first():
61
- sa_role = Role(name="SuperAdmin", description="Full system access")
62
- admin_role = Role(name="Admin", description="Administrative access")
63
- user_role = Role(name="User", description="Standard user access")
64
- session.add_all([sa_role, admin_role, user_role])
65
-
66
- perms = []
67
- for code, desc in [
68
- ("users:read", "Read users"),
69
- ("users:write", "Modify users"),
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
- from app.services.auth_service import AuthService
101
- await AuthService.cleanup_expired_sessions(session)
102
 
103
 
104
- async def get_db() -> AsyncGenerator[AsyncSession, None]:
105
- async with AsyncSessionLocal() as session:
106
- try:
107
- yield session
108
- finally:
109
- await session.close()
 
 
110
 
111
 
112
  async def get_current_user(
113
  request: Request,
114
- db: Annotated[AsyncSession, Depends(get_db)],
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
- result = await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None)))
138
- user = result.scalars().first()
139
- if user is None or not user.is_active:
 
 
 
 
 
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
- for role in user.roles:
168
- for perm in role.permissions:
169
- user_perms.add(perm.code)
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
- user_roles = Table(
25
- "user_roles",
26
- Base.metadata,
27
- Column("user_id", String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
28
- Column("role_id", String(36), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
29
- )
30
-
31
- role_permissions = Table(
32
- "role_permissions",
33
- Base.metadata,
34
- Column("role_id", String(36), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
35
- Column("permission_id", String(36), ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True),
36
- )
37
-
38
-
39
- class Permission(Base):
40
- __tablename__ = "permissions"
41
-
42
- id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
43
- code: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False)
44
- description: Mapped[str | None] = mapped_column(String(255), nullable=True)
45
-
46
- roles: Mapped[list[Role]] = relationship(secondary=role_permissions, back_populates="permissions", lazy="selectin")
47
-
48
-
49
- class Role(Base):
50
- __tablename__ = "roles"
51
-
52
- id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
53
- name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
54
- description: Mapped[str | None] = mapped_column(String(255), nullable=True)
55
-
56
- users: Mapped[list[User]] = relationship(secondary=user_roles, back_populates="roles", lazy="selectin")
57
- permissions: Mapped[list[Permission]] = relationship(secondary=role_permissions, back_populates="roles", lazy="selectin")
58
-
59
-
60
- class User(Base):
61
- __tablename__ = "users"
62
-
63
- id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
64
- email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
65
- username: Mapped[str | None] = mapped_column(String(50), unique=True, index=True, nullable=True)
66
- full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
67
- password_hash: Mapped[str] = mapped_column(Text, nullable=False)
68
-
69
- is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
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 os
4
- from typing import AsyncGenerator
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.vector_store.models import Base
 
 
10
 
11
  _settings = get_settings()
12
 
13
- DATA_DIR = _settings.data_dir
14
- os.makedirs(DATA_DIR, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
15
 
16
- VECTOR_STORE_DB_URL = f"sqlite+aiosqlite:///{os.path.join(DATA_DIR, 'vector_stores.db')}"
17
 
18
- engine = create_async_engine(VECTOR_STORE_DB_URL, echo=False)
19
- AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
 
 
 
 
 
20
 
21
 
22
- async def init_vector_store_db():
23
- async with engine.begin() as conn:
24
- await conn.run_sync(Base.metadata.create_all)
 
 
 
 
 
 
 
 
 
 
25
 
26
 
27
- async def get_vs_db() -> AsyncGenerator[AsyncSession, None]:
28
- async with AsyncSessionLocal() as session:
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
- class VectorStoreIndex(Base):
20
- __tablename__ = "vector_store_index"
21
-
22
- store_id: Mapped[str] = mapped_column(String(36), primary_key=True)
23
- name: Mapped[str] = mapped_column(String(255), nullable=False)
24
- path: Mapped[str] = mapped_column(String(1024), nullable=False)
25
- description: Mapped[str] = mapped_column(String(1024), default="")
26
- metadata_json: Mapped[str] = mapped_column(Text, default="{}")
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: AsyncSession, schema: RegisterSchema) -> User:
42
- result = await db.execute(select(User).where(User.email == schema.email))
43
- if result.scalars().first():
 
 
 
44
  raise HTTPException(status_code=409, detail="Email already registered")
45
 
46
  if schema.username:
47
- result = await db.execute(select(User).where(User.username == schema.username))
48
- if result.scalars().first():
 
 
 
49
  raise HTTPException(status_code=409, detail="Username already taken")
50
 
51
- user = User(
52
- email=schema.email,
53
- username=schema.username,
54
- full_name=schema.full_name,
55
- password_hash=ph.hash(schema.password),
 
56
  )
57
 
58
- role_result = await db.execute(select(Role).where(Role.name == "User"))
59
- default_role = role_result.scalars().first()
60
- if default_role:
61
- user.roles.append(default_role)
 
 
62
 
63
- db.add(user)
64
- await db.commit()
65
- await db.refresh(user)
66
- return user
67
 
68
  @staticmethod
69
- async def login(db: AsyncSession, request: Request, schema: LoginSchema) -> TokenData:
70
- result = await db.execute(
71
- select(User).where(User.email == schema.email, User.deleted_at.is_(None))
 
72
  )
73
- user = result.scalars().first()
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.commit()
 
 
 
89
  raise HTTPException(status_code=401, detail="Incorrect email or password")
90
 
91
- user.failed_login_attempts = 0
92
- user.locked_until = None
93
- user.last_login = _now()
 
 
 
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
- session = RefreshSession(
99
- user_id=user.id,
100
- token_key=token_key,
101
- token_hash=refresh_hash,
102
- expires_at=expires_at,
103
- device_info=request.headers.get("User-Agent", "Unknown"),
104
- ip_address=request.client.host if request.client else "Unknown",
 
 
 
 
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: AsyncSession, raw_refresh_token: str) -> TokenData:
113
  key = _token_key(raw_refresh_token)
114
 
115
- result = await db.execute(
116
- select(RefreshSession).where(
117
- RefreshSession.token_key == key,
118
- RefreshSession.revoked_at.is_(None),
119
- RefreshSession.expires_at > _now(),
120
- )
121
  )
122
- session = result.scalars().first()
123
-
124
- if not session:
125
- session = await db.execute(
126
- select(RefreshSession).where(RefreshSession.token_key == key)
127
  )
128
- existing = session.scalars().first()
129
- if existing and existing.revoked_at is not None:
130
- await db.execute(
131
- update(RefreshSession)
132
- .where(RefreshSession.user_id == existing.user_id, RefreshSession.revoked_at.is_(None))
133
- .values(revoked_at=_now())
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
- user_result = await db.execute(
146
- select(User).where(
147
- User.id == session.user_id, User.deleted_at.is_(None), User.is_active.is_(True)
148
- )
149
  )
150
- user = user_result.scalars().first()
151
- if not user:
152
  raise HTTPException(status_code=401, detail="User not found or inactive")
 
153
 
154
- session.revoked_at = _now()
 
 
 
 
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
- new_session = RefreshSession(
160
- user_id=user.id,
161
- token_key=new_key,
162
- token_hash=new_hash,
163
- expires_at=new_expires,
164
- device_info=session.device_info,
165
- ip_address=session.ip_address,
 
 
 
166
  )
167
- db.add(new_session)
168
 
169
- user.last_login = _now()
170
- await db.commit()
 
 
171
 
172
  return TokenData(access_token=new_access, refresh_token=new_raw_refresh)
173
 
174
  @staticmethod
175
- async def logout(db: AsyncSession, user: User, raw_refresh_token: str):
176
  key = _token_key(raw_refresh_token)
177
- result = await db.execute(
178
- select(RefreshSession).where(
179
- RefreshSession.token_key == key,
180
- RefreshSession.user_id == user.id,
181
- RefreshSession.revoked_at.is_(None),
182
- )
183
  )
184
- session = result.scalars().first()
185
- if not session:
186
  raise HTTPException(status_code=404, detail="Session not found")
187
- session.revoked_at = _now()
188
- await db.commit()
 
 
189
 
190
  @staticmethod
191
- async def logout_all(db: AsyncSession, user: User):
192
- now = _now()
193
  await db.execute(
194
- update(RefreshSession)
195
- .where(
196
- RefreshSession.user_id == user.id,
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: AsyncSession, user: User, schema: ChangePasswordSchema):
205
  if not AuthService._verify_password(schema.current_password, user.password_hash):
206
  raise HTTPException(status_code=400, detail="Incorrect current password")
207
- user.password_hash = ph.hash(schema.new_password)
208
- user.password_changed_at = _now()
209
- await db.commit()
 
 
210
 
211
  @staticmethod
212
- async def forgot_password(db: AsyncSession, schema: ForgotPasswordSchema):
213
- result = await db.execute(
214
- select(User).where(User.email == schema.email, User.deleted_at.is_(None))
 
215
  )
216
- user = result.scalars().first()
217
- if user:
218
  reset_token = jwt.encode(
219
  {
220
- "sub": user.id,
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", user.email, reset_token)
228
 
229
  @staticmethod
230
- async def reset_password(db: AsyncSession, schema: ResetPasswordSchema):
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
- result = await db.execute(select(User).where(User.id == user_id))
244
- user = result.scalars().first()
245
- if not user:
246
  raise HTTPException(status_code=404, detail="User not found")
247
 
248
- user.password_hash = ph.hash(schema.new_password)
249
- user.password_changed_at = _now()
250
-
 
 
251
  await db.execute(
252
- update(RefreshSession)
253
- .where(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None))
254
- .values(revoked_at=_now())
255
  )
256
- await db.commit()
257
 
258
  @staticmethod
259
- async def update_profile(db: AsyncSession, user: User, schema: UpdateProfileSchema) -> User:
260
  if schema.username is not None:
261
- result = await db.execute(
262
- select(User).where(User.username == schema.username, User.id != user.id)
 
263
  )
264
- if result.scalars().first():
265
  raise HTTPException(status_code=409, detail="Username already taken")
266
- user.username = schema.username
267
  if schema.full_name is not None:
268
- user.full_name = schema.full_name
269
- await db.commit()
270
- await db.refresh(user)
271
- return user
 
 
272
 
273
  @staticmethod
274
- async def soft_delete(db: AsyncSession, user: User):
275
- user.deleted_at = _now()
276
- user.is_active = False
 
 
 
277
  await db.execute(
278
- update(RefreshSession)
279
- .where(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None))
280
- .values(revoked_at=_now())
281
  )
282
- await db.commit()
283
 
284
  @staticmethod
285
- async def list_sessions(db: AsyncSession, user: User) -> list[RefreshSession]:
286
- result = await db.execute(
287
- select(RefreshSession)
288
- .where(
289
- RefreshSession.user_id == user.id,
290
- RefreshSession.revoked_at.is_(None),
291
- )
292
- .order_by(RefreshSession.created_at.desc())
293
  )
294
- return list(result.scalars().all())
295
 
296
  @staticmethod
297
- async def revoke_session(db: AsyncSession, user: User, session_id: str):
298
- result = await db.execute(
299
- select(RefreshSession).where(
300
- RefreshSession.id == session_id,
301
- RefreshSession.user_id == user.id,
302
- RefreshSession.revoked_at.is_(None),
303
- )
304
  )
305
- session = result.scalars().first()
306
- if not session:
307
  raise HTTPException(status_code=404, detail="Session not found")
308
- session.revoked_at = _now()
309
- await db.commit()
 
 
310
 
311
  @staticmethod
312
- async def cleanup_expired_sessions(db: AsyncSession):
313
- result = await db.execute(
314
- select(RefreshSession).where(RefreshSession.expires_at < _now())
 
315
  )
316
  count = 0
317
- for s in result.scalars().all():
318
- if s.revoked_at is None:
319
- s.revoked_at = _now()
 
 
 
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=[r.name for r in user.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 sys
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 uuid as _uuid
 
 
 
 
 
 
 
 
25
  import secrets as _secrets
26
- import enum as _enum
27
- import base64 as _base64
 
 
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
- import json as _json
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, db_path: str):
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 create_owner(self, owner_id: str, name: str, api_key_hash: str, plan: str = "free") -> None:
433
- with self._lock, self._conn as conn:
434
- conn.execute(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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[sqlite3.Row]:
441
- return self._conn.execute(
442
- "SELECT * FROM url_shortener_owners WHERE api_key_hash = ?", (api_key_hash,)
443
- ).fetchone()
 
444
 
445
- def get_owner(self, owner_id: str) -> Optional[sqlite3.Row]:
446
- return self._conn.execute(
447
- "SELECT * FROM url_shortener_owners WHERE owner_id = ?", (owner_id,)
448
- ).fetchone()
 
449
 
450
- def update_owner_plan(self, owner_id: str, plan: str) -> None:
451
- with self._lock, self._conn as conn:
452
- conn.execute("UPDATE url_shortener_owners SET plan = ? WHERE owner_id = ?", (plan, owner_id))
 
 
 
 
 
453
 
454
  def code_exists(self, short_code: str) -> bool:
455
- return self._conn.execute(
456
- "SELECT 1 FROM url_shortener_links WHERE short_code = ?", (short_code,)
457
- ).fetchone() is not None
 
 
458
 
459
- def insert_link(self, **kwargs: Any) -> None:
460
- with self._lock, self._conn as conn:
461
- conn.execute("""
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
- (:short_code, :long_url, :owner_id, :created_at, :expires_at,
469
- :max_clicks, 0, NULL, 1, :tags, :note,
470
- :password_hash, :utm_source, :utm_medium, :utm_campaign,
471
- :campaign_id, :custom_domain, :fallback_url, :webhook_url,
472
- :geo_targeting)
473
- """, kwargs)
474
-
475
- def update_link(self, short_code: str, **updates: Any) -> None:
476
- with self._lock, self._conn as conn:
477
- sets = ", ".join(f"{k} = ?" for k in updates)
478
  vals = list(updates.values()) + [short_code]
479
- conn.execute(f"UPDATE url_shortener_links SET {sets} WHERE short_code = ?", vals)
480
-
481
- def get_link(self, short_code: str) -> Optional[sqlite3.Row]:
482
- return self._conn.execute(
483
- "SELECT * FROM url_shortener_links WHERE short_code = ?", (short_code,)
484
- ).fetchone()
485
-
486
- def list_links_for_owner(self, owner_id: str) -> List[sqlite3.Row]:
487
- return self._conn.execute(
488
- "SELECT * FROM url_shortener_links WHERE owner_id = ? ORDER BY created_at DESC",
 
 
 
 
489
  (owner_id,),
490
- ).fetchall()
491
 
492
- def deactivate_link(self, short_code: str) -> None:
493
- with self._lock, self._conn as conn:
494
- conn.execute("UPDATE url_shortener_links SET is_active = 0 WHERE short_code = ?", (short_code,))
 
 
 
495
 
496
  def record_click(self, short_code: str, referrer: Optional[str] = None,
497
- user_agent: Optional[str] = None, ip_address: Optional[str] = None) -> None:
498
  now = _now_iso()
499
  parsed = _parse_user_agent(user_agent or "")
500
- with self._lock, self._conn as conn:
501
- conn.execute(
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
- conn.execute(
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
- total = self._conn.execute(
516
- "SELECT COUNT(*) FROM url_shortener_clicks WHERE short_code = ?", (short_code,)
517
- ).fetchone()[0]
518
- browsers = dict(self._conn.execute(
519
- "SELECT browser, COUNT(*) FROM url_shortener_clicks WHERE short_code = ? "
520
- "AND browser IS NOT NULL GROUP BY browser ORDER BY COUNT(*) DESC",
521
- (short_code,)
522
- ).fetchall())
523
- devices = dict(self._conn.execute(
524
- "SELECT device, COUNT(*) FROM url_shortener_clicks WHERE short_code = ? "
525
- "AND device IS NOT NULL GROUP BY device ORDER BY COUNT(*) DESC",
526
- (short_code,)
527
- ).fetchall())
528
- referrers = dict(self._conn.execute(
 
 
 
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
- ).fetchall())
534
- os_data = dict(self._conn.execute(
535
- "SELECT os, COUNT(*) FROM url_shortener_clicks WHERE short_code = ? "
536
- "AND os IS NOT NULL GROUP BY os ORDER BY COUNT(*) DESC",
537
- (short_code,)
538
- ).fetchall())
539
- recent = self._conn.execute(
540
- "SELECT clicked_at FROM url_shortener_clicks WHERE short_code = ? "
541
  "ORDER BY clicked_at DESC LIMIT 50",
542
- (short_code,)
543
- ).fetchall()
544
  return {
545
- "total_clicks": total,
546
- "browsers": browsers,
547
- "devices": devices,
548
- "operating_systems": os_data,
549
- "top_referrers": 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
- return self._conn.execute(
555
- "SELECT COUNT(*) FROM url_shortener_links WHERE owner_id = ?", (owner_id,)
556
- ).fetchone()[0]
 
 
557
 
558
- def audit(self, owner_id: Optional[str], action: str, short_code: Optional[str], detail: str = "") -> None:
559
- with self._lock, self._conn as conn:
560
- conn.execute(
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._conn.execute(
568
  "SELECT short_code, long_url, created_at, click_count, is_active, tags, note "
569
- "FROM url_shortener_links WHERE owner_id = ? ORDER BY created_at DESC", (owner_id,)
570
- ).fetchall()
 
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._conn.execute(
581
  "SELECT id, referrer, user_agent, ip_address, browser, device, os, clicked_at "
582
- "FROM url_shortener_clicks WHERE short_code = ? ORDER BY clicked_at DESC", (short_code,)
583
- ).fetchall()
 
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[sqlite3.Row]:
593
- return self._conn.execute(
594
- "SELECT * FROM url_shortener_links WHERE campaign_id = ? ORDER BY created_at DESC",
595
  (campaign_id,),
596
- ).fetchall()
597
 
598
- def create_campaign(self, campaign_id: str, owner_id: str, name: str, description: Optional[str] = None) -> None:
599
- with self._lock, self._conn as conn:
600
- conn.execute(
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[sqlite3.Row]:
607
- return self._conn.execute(
608
- "SELECT * FROM url_shortener_campaigns WHERE campaign_id = ?", (campaign_id,)
609
- ).fetchone()
 
610
 
611
- def list_campaigns_for_owner(self, owner_id: str) -> List[sqlite3.Row]:
612
- return self._conn.execute(
613
- "SELECT * FROM url_shortener_campaigns WHERE owner_id = ? ORDER BY created_at DESC",
614
  (owner_id,),
615
- ).fetchall()
616
 
617
- def deactivate_campaign(self, campaign_id: str) -> None:
618
- with self._lock, self._conn as conn:
619
- conn.execute(
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
- return self._conn.execute(
626
- "SELECT COUNT(*) FROM url_shortener_links WHERE campaign_id = ?", (campaign_id,)
627
- ).fetchone()[0]
 
 
628
 
629
  def get_campaign_total_clicks(self, campaign_id: str) -> int:
630
- return self._conn.execute(
631
- "SELECT COALESCE(SUM(click_count), 0) FROM url_shortener_links WHERE campaign_id = ?",
632
  (campaign_id,),
633
- ).fetchone()[0]
 
634
 
635
  def get_campaign_analytics(self, campaign_id: str) -> Dict[str, Any]:
636
- codes = self._conn.execute(
637
- "SELECT short_code FROM url_shortener_links WHERE campaign_id = ?", (campaign_id,)
638
- ).fetchall()
 
639
  total = 0
640
  browsers: Dict[str, int] = {}
641
  devices: Dict[str, int] = {}
642
  os_data: Dict[str, int] = {}
643
- for (code,) in codes:
644
- total += self._conn.execute(
645
- "SELECT COUNT(*) FROM url_shortener_clicks WHERE short_code = ?", (code,)
646
- ).fetchone()[0]
647
- for b, n in self._conn.execute(
648
- "SELECT browser, COUNT(*) FROM url_shortener_clicks WHERE short_code = ? "
649
- "AND browser IS NOT NULL GROUP BY browser", (code,)
650
- ).fetchall():
651
- browsers[b] = browsers.get(b, 0) + n
652
- for d, n in self._conn.execute(
653
- "SELECT device, COUNT(*) FROM url_shortener_clicks WHERE short_code = ? "
654
- "AND device IS NOT NULL GROUP BY device", (code,)
655
- ).fetchall():
656
- devices[d] = devices.get(d, 0) + n
657
- for o, n in self._conn.execute(
658
- "SELECT os, COUNT(*) FROM url_shortener_clicks WHERE short_code = ? "
659
- "AND os IS NOT NULL GROUP BY os", (code,)
660
- ).fetchall():
661
- os_data[o] = os_data.get(o, 0) + n
 
 
 
 
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
- total_clicks = self._conn.execute(
670
- "SELECT COALESCE(SUM(click_count), 0) FROM url_shortener_links WHERE owner_id = ?",
671
- (owner_id,)
672
- ).fetchone()[0]
673
- active = self._conn.execute(
674
- "SELECT COUNT(*) FROM url_shortener_links WHERE owner_id = ? AND is_active = 1",
675
- (owner_id,)
676
- ).fetchone()[0]
 
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, db_path: Optional[str] = None):
690
- self.db_path = db_path or os.environ.get("URL_SHORTENER_DB", "data/url_shortener.db")
691
- self.storage = Storage(self.db_path)
692
- logger.info("URLShortenerService initialized (db=%s)", self.db_path)
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
- self,
732
- long_url: str,
733
- owner_id: str,
734
- custom_alias: Optional[str] = None,
735
- expires_in_days: Optional[int] = None,
736
- expires_at: Optional[datetime] = None,
737
- max_clicks: Optional[int] = None,
738
- tags: Optional[List[str]] = None,
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.writer_lock():
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.writer_lock():
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.writer_lock():
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: Any) -> ShortLink:
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
- rows = self.storage._conn.execute(
 
 
 
1052
  "SELECT id, referrer, user_agent, ip_address, browser, device, os, clicked_at "
1053
- "FROM url_shortener_clicks WHERE short_code = ? ORDER BY clicked_at DESC LIMIT ?",
1054
  (short_code, limit),
1055
- ).fetchall()
1056
- return [dict(r) for r in rows]
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: sqlite3.Row) -> ShortLink:
1174
- gt_raw = row["geo_targeting"]
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["tags"] else [],
1186
- note=row["note"],
1187
- password_hash=row["password_hash"],
1188
- utm_source=row["utm_source"],
1189
- utm_medium=row["utm_medium"],
1190
- utm_campaign=row["utm_campaign"],
1191
- campaign_id=row["campaign_id"],
1192
- custom_domain=row["custom_domain"],
1193
- fallback_url=row["fallback_url"],
1194
- webhook_url=row["webhook_url"],
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.vector_store.deps import AsyncSessionLocal
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
- # --- SQLite persistence ---
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
- async with AsyncSessionLocal() as session:
73
- result = await session.execute(select(VectorStoreIndex))
74
- rows = result.scalars().all()
75
- for row in rows:
76
- d = row.to_dict()
77
- record = VectorStoreRecord(
78
- store_id=d["store_id"],
79
- name=d["name"],
80
- path=d["path"],
81
- description=d["description"],
82
- metadata=d["metadata"],
83
- created_at=d["created_at"],
84
- )
85
- self._stores[record.store_id] = record
86
- store_path = d["path"]
87
- if os.path.exists(os.path.join(store_path, "__zvec_meta")):
88
- try:
89
- col = zvec.open(store_path)
90
- if col is not None:
91
- self._collections[record.store_id] = col
92
- except Exception as exc:
93
- logger.warning("Could not open collection %s: %s", record.store_id, exc)
 
 
 
94
 
95
  async def _persist_store(self, record: VectorStoreRecord) -> None:
96
- async with AsyncSessionLocal() as session:
97
- existing = await session.get(VectorStoreIndex, record.store_id)
98
- if existing:
99
- existing.name = record.name
100
- existing.description = record.description
101
- existing.metadata_json = json.dumps(record.metadata)
102
- else:
103
- session.add(VectorStoreIndex.from_dict({
104
- "store_id": record.store_id,
105
- "name": record.name,
106
- "path": record.path,
107
- "description": record.description,
108
- "metadata": record.metadata,
109
- "created_at": record.created_at,
110
- }))
111
- await session.commit()
 
 
 
 
 
 
 
 
112
 
113
  async def _remove_persisted_store(self, store_id: str) -> None:
114
- async with AsyncSessionLocal() as session:
115
- row = await session.get(VectorStoreIndex, store_id)
116
- if row:
117
- await session.delete(row)
118
- await session.commit()
 
 
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 carrier, geocoder, PhoneNumberType
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