diff --git a/.gitignore b/.gitignore index e414c715..6c37af14 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,5 @@ cython_debug/ # PyPI configuration file .pypirc + +.DS_Store \ No newline at end of file diff --git a/backend/apps/system/api/login.py b/backend/apps/system/api/login.py index 6abefd53..9d7b87b8 100644 --- a/backend/apps/system/api/login.py +++ b/backend/apps/system/api/login.py @@ -1,22 +1,24 @@ -from fastapi import APIRouter, HTTPException -from apps.system.schemas.auth import LocalLoginSchema +from typing import Annotated +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import OAuth2PasswordRequestForm from common.core.deps import SessionDep from ..crud.user import authenticate from common.core.security import create_access_token from datetime import timedelta from common.core.config import settings +from common.core.schemas import Token +router = APIRouter(tags=["login"], prefix="/login") -router = APIRouter(tags=["login"]) - -@router.post("/localLogin") +@router.post("/access-token") def local_login( session: SessionDep, - schema: LocalLoginSchema, -) -> str: - user = authenticate(session=session, account=schema.account, password=schema.password) + form_data: Annotated[OAuth2PasswordRequestForm, Depends()] +) -> Token: + user = authenticate(session=session, account=form_data.username, password=form_data.password) if not user: raise HTTPException(status_code=400, detail="Incorrect account or password") access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - return create_access_token( - user.id, expires_delta=access_token_expires - ) \ No newline at end of file + user_dict = user.to_dict() + return Token(access_token=create_access_token( + user_dict, expires_delta=access_token_expires + )) \ No newline at end of file diff --git a/backend/apps/system/api/user.py b/backend/apps/system/api/user.py index d1ddbf28..dadfa574 100644 --- a/backend/apps/system/api/user.py +++ b/backend/apps/system/api/user.py @@ -5,7 +5,5 @@ router = APIRouter(tags=["user"], prefix="/user") @router.get("/info") -async def user_info(): - return { - "name": "admin" - } \ No newline at end of file +async def user_info(current_user: CurrentUser): + return current_user.to_dict() \ No newline at end of file diff --git a/backend/apps/system/middleware/__init__.py b/backend/apps/system/middleware/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/apps/system/middleware/token.py b/backend/apps/system/middleware/token.py deleted file mode 100644 index 05736685..00000000 --- a/backend/apps/system/middleware/token.py +++ /dev/null @@ -1,23 +0,0 @@ - -from fastapi import HTTPException, Request -from common.core.config import settings -from common.core.deps import get_current_user - - - - -async def verify_token(request: Request, call_next): - print(request.url.path) - if request.url.path == "/login/localLogin": - return await call_next(request) - try: - key = settings.TOKEN_KEY - token = request.headers.get(key) - get_current_user(token) - except (Exception): - return HTTPException(status_code=401, detail="Invalid token") - response = await call_next(request) - return response - - - \ No newline at end of file diff --git a/backend/apps/system/models/user.py b/backend/apps/system/models/user.py index e8fe61d6..b3063725 100644 --- a/backend/apps/system/models/user.py +++ b/backend/apps/system/models/user.py @@ -6,3 +6,10 @@ class sys_user(SQLModel, table=True): account: str = Field(max_length=255, unique=True) password: str = Field(max_length=255) oid: int = Field(default=1) + + def to_dict(self): + return { + "id": self.id, + "account": self.account, + "oid": self.oid + } \ No newline at end of file diff --git a/backend/common/core/deps.py b/backend/common/core/deps.py index bdcd7a18..a3e2fa17 100644 --- a/backend/common/core/deps.py +++ b/backend/common/core/deps.py @@ -2,48 +2,26 @@ from typing import Annotated import jwt from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer +# from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from sqlmodel import Session - +from common.core.schemas import TokenPayload, XOAuth2PasswordBearer from common.core import security from common.core.config import settings from common.core.db import get_session from apps.system.models.user import sys_user -import threading -""" reusable_oauth2 = OAuth2PasswordBearer( +reusable_oauth2 = XOAuth2PasswordBearer( tokenUrl=f"{settings.API_V1_STR}/login/access-token" ) - """ -thread_local = threading.local() + + SessionDep = Annotated[Session, Depends(get_session)] -# TokenDep = Annotated[str, Depends(reusable_oauth2)] +TokenDep = Annotated[str, Depends(reusable_oauth2)] -def get_current_user(session: SessionDep, token: str) -> sys_user: - try: - payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] - ) - # token_data = TokenPayload(**payload) - except (InvalidTokenError, ValidationError): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Could not validate credentials", - ) - user = session.get(sys_user, payload.sub) - if not user: - raise HTTPException(status_code=404, detail="User not found") - """ if not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") """ - thread_local.current_user = user - return user -def get_current_user_from_thread(): - return getattr(thread_local, "current_user", None) -CurrentUser = Annotated[sys_user, Depends(get_current_user_from_thread)] -""" def get_current_user(session: SessionDep, token: TokenDep) -> User: +def get_current_user(session: SessionDep, token: TokenDep) -> sys_user: try: payload = jwt.decode( token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] @@ -54,20 +32,13 @@ CurrentUser = Annotated[sys_user, Depends(get_current_user_from_thread)] status_code=status.HTTP_403_FORBIDDEN, detail="Could not validate credentials", ) - user = session.get(User, token_data.sub) + user = session.get(sys_user, token_data.id) if not user: raise HTTPException(status_code=404, detail="User not found") - if not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") + """ if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") """ return user +CurrentUser = Annotated[sys_user, Depends(get_current_user)] -CurrentUser = Annotated[User, Depends(get_current_user)] - -def get_current_active_superuser(current_user: CurrentUser) -> User: - if not current_user.is_superuser: - raise HTTPException( - status_code=403, detail="The user doesn't have enough privileges" - ) - return current_user """ diff --git a/backend/common/core/schemas.py b/backend/common/core/schemas.py new file mode 100644 index 00000000..d383da6d --- /dev/null +++ b/backend/common/core/schemas.py @@ -0,0 +1,31 @@ +from typing import Optional +from fastapi import HTTPException, Request +from fastapi.security import OAuth2PasswordBearer +from pydantic import BaseModel +from sqlmodel import SQLModel +from starlette.status import HTTP_403_FORBIDDEN, HTTP_401_UNAUTHORIZED +from common.core.config import settings +from fastapi.security.utils import get_authorization_scheme_param +class TokenPayload(BaseModel): + account: str | None = None + id: int | None = None + oid: int | None = None + +class Token(SQLModel): + access_token: str + token_type: str = "bearer" + +class XOAuth2PasswordBearer(OAuth2PasswordBearer): + async def __call__(self, request: Request) -> Optional[str]: + authorization = request.headers.get(settings.TOKEN_KEY) + scheme, param = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != "bearer": + if self.auto_error: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + return None + return param \ No newline at end of file diff --git a/backend/common/core/security.py b/backend/common/core/security.py index c678590c..4ab4b25c 100644 --- a/backend/common/core/security.py +++ b/backend/common/core/security.py @@ -12,9 +12,11 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") ALGORITHM = "HS256" -def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: +def create_access_token(data: dict | Any, expires_delta: timedelta) -> str: + to_encode = data.copy() expire = datetime.now(timezone.utc) + expires_delta - to_encode = {"exp": expire, "account": str(subject)} + to_encode.update({"exp": expire}) + # to_encode = {"exp": expire, "account": str(subject)} encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt diff --git a/backend/main.py b/backend/main.py index 0d89a72c..ca54a807 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,11 +2,9 @@ import sentry_sdk from fastapi import FastAPI from fastapi.routing import APIRoute from starlette.middleware.cors import CORSMiddleware -from starlette.middleware.base import BaseHTTPMiddleware from apps.api import api_router from common.core.config import settings -from apps.system.middleware.token import verify_token def custom_generate_unique_id(route: APIRoute) -> str: return f"{route.tags[0]}-{route.name}" @@ -32,7 +30,6 @@ if settings.all_cors_origins: ) app.include_router(api_router, prefix=settings.API_V1_STR) -# app.add_middleware(BaseHTTPMiddleware, dispatch=verify_token) if __name__ == "__main__": import uvicorn diff --git a/frontend/.env.development b/frontend/.env.development index b895188d..f41acbae 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,2 +1,2 @@ -VITE_API_BASE_URL=http://localhost:3000 +VITE_API_BASE_URL=http://localhost:8000/api/v1 VITE_APP_TITLE=SQLBot (Development) \ No newline at end of file diff --git a/frontend/.env.production b/frontend/.env.production index 58490b22..ab886500 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1,2 +1,2 @@ -VITE_API_BASE_URL=https://api.SQLBot.com +VITE_API_BASE_URL=./ VITE_APP_TITLE=SQLBot \ No newline at end of file diff --git a/frontend/auto-imports.d.ts b/frontend/auto-imports.d.ts index 9d240079..76e1ee65 100644 --- a/frontend/auto-imports.d.ts +++ b/frontend/auto-imports.d.ts @@ -6,5 +6,5 @@ // biome-ignore lint: disable export {} declare global { - + const ElMessage: typeof import('element-plus/es')['ElMessage'] } diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 2eb57c6e..dc9c610f 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -11,6 +11,7 @@ declare module 'vue' { ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] + ElDialog: typeof import('element-plus/es')['ElDialog'] ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] diff --git a/frontend/package.json b/frontend/package.json index 74b60165..96a24441 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,8 @@ }, "dependencies": { "vue": "^3.5.13", - "vue-router": "^4.5.0" + "vue-router": "^4.5.0", + "web-storage-cache": "^1.1.1" }, "devDependencies": { "@element-plus/icons-vue": "^2.3.1", diff --git a/frontend/src/api/login.ts b/frontend/src/api/login.ts new file mode 100644 index 00000000..10b7bf4a --- /dev/null +++ b/frontend/src/api/login.ts @@ -0,0 +1,15 @@ +import { request } from '@/utils/request' + +export const AuthApi = { + login: (credentials: { username: string; password: string }) => request.post<{ + data: any; token: string +}>('/login/access-token', credentials, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + requestOptions: { + rawResponse: true, + } + }), + logout: () => request.post('/auth/logout') +} \ No newline at end of file diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 0e97fc11..963f75c0 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -6,6 +6,7 @@ import chat from '@/views/chat/index.vue' import ds from '@/views/ds/index.vue' import dashboard from '@/views/dashboard/index.vue' import setting from '@/views/setting/index.vue' +import { watchRouter } from './watch' const router = createRouter({ history: createWebHistory(), routes: [ @@ -68,5 +69,5 @@ const router = createRouter({ } ] }) - +watchRouter(router) export default router \ No newline at end of file diff --git a/frontend/src/router/watch.ts b/frontend/src/router/watch.ts new file mode 100644 index 00000000..d8fb0027 --- /dev/null +++ b/frontend/src/router/watch.ts @@ -0,0 +1,32 @@ +import { ElMessage } from 'element-plus' +import { useCache } from '@/utils/useCache' +const { wsCache } = useCache() +// 不需要验证token的白名单路由 +const whiteList = ['/login'] +export const watchRouter = (router: any) => { + router.beforeEach(async (to: any, from: any, next: any) => { + // 获取token + const token = wsCache.get('user.token') + // 如果没有token + if (!token) { + if (whiteList.includes(to.path)) { + next() + } else { + ElMessage.error('请先登录') + next('/login') + } + return + } + // 处理根路径重定向 + if (to.path === '/') { + next('/chat') + return + } + // 已有token和用户信息,允许访问 + if (to.path === '/login') { + next('/chat') + } else { + next() + } + }) +} diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index 03041412..6652dae6 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -1,19 +1,24 @@ import { defineStore } from 'pinia' import { ref } from 'vue' - +import { AuthApi } from '@/api/login' +import { useCache } from '@/utils/useCache' +const { wsCache } = useCache() export const useUserStore = defineStore('user', () => { const token = ref('') const username = ref('') const login = async (formData: { username: string; password: string }) => { - token.value = 'mock-token' username.value = formData.username - return Promise.resolve() + const res = await AuthApi.login(formData) + token.value = res.data.access_token + wsCache.set('user.token', token.value) + username.value = formData.username } const logout = () => { token.value = '' username.value = '' + wsCache.delete('user.token') } return { diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts new file mode 100644 index 00000000..a9a34309 --- /dev/null +++ b/frontend/src/utils/request.ts @@ -0,0 +1,260 @@ +// src/services/request.ts +import axios, { + AxiosError, + type AxiosInstance, + type AxiosRequestConfig, + type AxiosResponse, + type InternalAxiosRequestConfig, + type CancelTokenSource +} from 'axios' + +import { useCache } from '@/utils/useCache' +const { wsCache } = useCache() +// Response data structure +export interface ApiResponse { + code: number + data: T + message: string + success: boolean + [key: string]: any // Allow additional fields +} + +// Extended request options +export interface RequestOptions { + silent?: boolean // Silent mode (no error alerts) + rawResponse?: boolean // Return raw Axios response + customError?: boolean // Custom error handling + retryCount?: number // Number of retry attempts +} + +// Merged request configuration +export interface FullRequestConfig extends AxiosRequestConfig { + requestOptions?: RequestOptions +} + +// Custom error type +export interface RequestError extends Error { + config: FullRequestConfig + code?: string + request?: any + response?: AxiosResponse + isAxiosError: boolean +} + +class HttpService { + private instance: AxiosInstance + private cancelTokenSource: CancelTokenSource + + constructor(config?: AxiosRequestConfig) { + this.cancelTokenSource = axios.CancelToken.source() + this.instance = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL, + timeout: 15000, + headers: { + 'Content-Type': 'application/json', + ...config?.headers + }, + ...config + }) + + this.setupInterceptors() + } + + private setupInterceptors() { + // Request interceptor + this.instance.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + // Add auth token + const token = wsCache.get('user.token') + if (token && config.headers) { + config.headers['X-SQLBOT_TOKEN'] = `Bearer ${token}` + } + + // Request logging + console.log(`[Request] ${config.method?.toUpperCase()} ${config.url}`) + + return config + }, + (error) => { + return Promise.reject(error) + } + ) + + // Response interceptor + this.instance.interceptors.response.use( + (response: AxiosResponse) => { + console.log(`[Response] ${response.config.url}`, response.data) + + // Return raw response if configured + if ((response.config as FullRequestConfig).requestOptions?.rawResponse) { + return response + } + + // Handle business logic + if (response.data?.success !== true) { + return Promise.reject(response.data) + } + + return response.data.data + }, + async (error: AxiosError) => { + const config = error.config as FullRequestConfig & { __retryCount?: number } + const requestOptions = config?.requestOptions || {} + + // Retry logic for specific status codes + const shouldRetry = + error.response?.status === 502 && + (config.__retryCount || 0) < (requestOptions.retryCount || 3) + + if (shouldRetry) { + config.__retryCount = (config.__retryCount || 0) + 1 + + // Exponential backoff + await new Promise(resolve => + setTimeout(resolve, 1000 * (config.__retryCount || 1)) + ) + + return this.instance.request(config) + } + + // Unified error handling + if (!requestOptions.customError && !requestOptions.silent) { + this.handleError(error) + } + + return Promise.reject(error) + } + ) + } + + private handleError(error: AxiosError) { + let errorMessage = 'Request error' + + if (error.response) { + switch (error.response.status) { + case 400: + errorMessage = 'Invalid request parameters' + break + case 401: + errorMessage = 'Unauthorized, please login again' + // Redirect to login page if needed + break + case 403: + errorMessage = 'Access denied' + break + case 404: + errorMessage = 'Resource not found' + break + case 500: + errorMessage = 'Server error' + break + default: + errorMessage = `Server responded with error: ${error.response.status}` + } + } else if (error.request) { + errorMessage = 'No response from server' + } else if (axios.isCancel(error)) { + errorMessage = 'Request canceled' + return // Skip showing cancel messages + } else { + errorMessage = error['message'] || 'Unknown error' + } + + // Show error using UI library (e.g., Element Plus, Ant Design) + console.error(errorMessage) + // ElMessage.error(errorMessage) + ElMessage({ + message: errorMessage, + type: 'error', + duration: 0 + }) + } + + // Cancel all pending requests + public cancelRequests(message?: string) { + this.cancelTokenSource.cancel(message) + // Create new token source for future requests + this.cancelTokenSource = axios.CancelToken.source() + } + + // Base request method + public request(config: FullRequestConfig): Promise { + return this.instance.request({ + cancelToken: this.cancelTokenSource.token, + ...config + }) + } + + // GET request + public get(url: string, config?: FullRequestConfig): Promise { + return this.request({ ...config, method: 'GET', url }) + } + + // POST request + public post( + url: string, + data?: any, + config?: FullRequestConfig + ): Promise { + return this.request({ ...config, method: 'POST', url, data }) + } + + // PUT request + public put( + url: string, + data?: any, + config?: FullRequestConfig + ): Promise { + return this.request({ ...config, method: 'PUT', url, data }) + } + + // DELETE request + public delete(url: string, config?: FullRequestConfig): Promise { + return this.request({ ...config, method: 'DELETE', url }) + } + + // PATCH request + public patch( + url: string, + data?: any, + config?: FullRequestConfig + ): Promise { + return this.request({ ...config, method: 'PATCH', url, data }) + } + + // File upload + public upload( + url: string, + file: File, + fieldName = 'file', + config?: FullRequestConfig + ): Promise { + const formData = new FormData() + formData.append(fieldName, file) + + return this.post(url, formData, { + headers: { + 'Content-Type': 'multipart/form-data' + }, + ...config + }) + } + + // Download file + public download( + url: string, + config?: FullRequestConfig + ): Promise { + return this.request({ + ...config, + method: 'GET', + url, + responseType: 'blob' + }) + } +} + +// Create singleton instance +export const request = new HttpService({ + baseURL: import.meta.env.VITE_API_BASE_URL +}) \ No newline at end of file diff --git a/frontend/src/utils/useCache.ts b/frontend/src/utils/useCache.ts new file mode 100644 index 00000000..a762baf5 --- /dev/null +++ b/frontend/src/utils/useCache.ts @@ -0,0 +1,17 @@ +/** + * 配置浏览器本地存储的方式,可直接存储对象数组。 + */ + +import WebStorageCache from 'web-storage-cache' + +type CacheType = 'sessionStorage' | 'localStorage' + +export const useCache = (type: CacheType = 'localStorage') => { + const wsCache: WebStorageCache = new WebStorageCache({ + storage: type + }) + + return { + wsCache + } +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 57ab202a..bf358d84 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -14,5 +14,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "auto-imports.d.ts"], }