diff --git a/README.md b/README.md index daf72d41..fc7d4b30 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,15 @@ TBD - Backend:[Python / Django](https://www.djangoproject.com/) - Database:[PostgreSQL + pgvector](https://www.postgresql.org/) +## 飞致云的其他明星项目 + +- [DataEase](https://github.com/dataease/dataease/) - 人人可用的开源 BI 工具 +- [1Panel](https://github.com/1panel-dev/1panel/) - 现代化、开源的 Linux 服务器运维管理面板 +- [MaxKB](https://github.com/1panel-dev/MaxKB/) - 基于 LLM 大语言模型的开源知识库问答系统 +- [JumpServer](https://github.com/jumpserver/jumpserver/) - 广受欢迎的开源堡垒机 +- [Halo](https://github.com/halo-dev/halo/) - 强大易用的开源建站工具 +- [MeterSphere](https://github.com/metersphere/metersphere/) - 新一代的开源持续测试工具 + ## Star History [](https://star-history.com/#1Panel-dev/SQLBot&Date) diff --git a/backend/apps/chat/task/llm.py b/backend/apps/chat/task/llm.py index fe0dd7bd..f501a63c 100644 --- a/backend/apps/chat/task/llm.py +++ b/backend/apps/chat/task/llm.py @@ -536,8 +536,8 @@ class LLMService: ).first() if obj is not None: res.append(transRecord2DTO(self.session, permission)) - wheres = transFilterTree(self.session, res, self.ds) - filters.append({"table": table.table_name, "filter": wheres}) + where_str = transFilterTree(self.session, res, self.ds) + filters.append({"table": table.table_name, "filter": where_str}) filter = json.dumps(filters, ensure_ascii=False) @@ -662,6 +662,16 @@ class LLMService: if data['type'] and data['type'] != 'error': # todo type check chart = data + if chart.get('columns'): + for v in chart.get('columns'): + v['value'] = v.get('value').lower() + if chart.get('axis'): + if chart.get('axis').get('x'): + chart.get('axis').get('x')['value'] = chart.get('axis').get('x').get('value').lower() + if chart.get('axis').get('y'): + chart.get('axis').get('y')['value'] = chart.get('axis').get('y').get('value').lower() + if chart.get('axis').get('series'): + chart.get('axis').get('series')['value'] = chart.get('axis').get('series').get('value').lower() elif data['type'] == 'error': message = data['reason'] error = True diff --git a/backend/apps/datasource/api/datasource.py b/backend/apps/datasource/api/datasource.py index a52ffc8b..7642c221 100644 --- a/backend/apps/datasource/api/datasource.py +++ b/backend/apps/datasource/api/datasource.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, File, UploadFile, HTTPException from apps.db.engine import create_table, get_data_engine, insert_data from common.core.deps import SessionDep, CurrentUser, Trans +from common.utils.utils import SQLBotLogUtil from ..crud.datasource import get_datasource_list, check_status, create_ds, update_ds, delete_ds, getTables, getFields, \ execSql, update_table_and_fields, getTablesByDs, chooseTables, preview, updateTable, updateField, get_ds, fieldEnum from ..crud.field import get_fields_by_table_id @@ -17,6 +18,11 @@ from ..models.datasource import CoreDatasource, CreateDatasource, TableObj, Core router = APIRouter(tags=["datasource"], prefix="/datasource") path = "/opt/sqlbot/data/excel" +@router.get("/ws/{oid}", include_in_schema=False) +async def query_by_oid(session: SessionDep, user: CurrentUser, oid: int) -> List[CoreDatasource]: + if not user.isAdmin: + raise Exception("no permission to execute") + return get_datasource_list(session=session, user=user, oid=oid) @router.get("/list") async def datasource_list(session: SessionDep, user: CurrentUser): @@ -60,7 +66,11 @@ async def get_tables(session: SessionDep, id: int): @router.post("/getTablesByConf") async def get_tables_by_conf(session: SessionDep, ds: CoreDatasource): - return getTablesByDs(session, ds) + try: + return getTablesByDs(session, ds) + except Exception as e: + SQLBotLogUtil.error(f"get table failed: {e}") + raise HTTPException(status_code=500, detail=f'Get table Failed: {e.args}') @router.post("/getFields/{id}/{table_name}") @@ -100,7 +110,11 @@ async def edit_field(session: SessionDep, field: CoreField): @router.post("/previewData/{id}") async def edit_local(session: SessionDep, current_user: CurrentUser, id: int, data: TableObj): - return preview(session, current_user, id, data) + try: + return preview(session, current_user, id, data) + except Exception as e: + SQLBotLogUtil.error(f"Preview failed: {e}") + raise HTTPException(status_code=500, detail=f'Preview Failed: {e.args}') @router.post("/fieldEnum/{id}") diff --git a/backend/apps/datasource/crud/datasource.py b/backend/apps/datasource/crud/datasource.py index 2e461291..52381c6f 100644 --- a/backend/apps/datasource/crud/datasource.py +++ b/backend/apps/datasource/crud/datasource.py @@ -1,6 +1,6 @@ import datetime import json -from typing import List +from typing import List, Optional from fastapi import HTTPException from sqlalchemy import and_, text, cast, or_, func @@ -25,10 +25,13 @@ from ..models.datasource import CoreDatasource, CreateDatasource, CoreTable, Cor DatasourceConf, TableAndFields -def get_datasource_list(session: SessionDep, user: CurrentUser): - oid = user.oid if user.oid is not None else 1 - return session.query(CoreDatasource).filter(CoreDatasource.oid == oid).order_by( - func.convert_to(CoreDatasource.name, 'gbk')).all() +def get_datasource_list(session: SessionDep, user: CurrentUser, oid: Optional[int] = None) -> List[CoreDatasource]: + current_oid = user.oid if user.oid is not None else 1 + if user.isAdmin and oid: + current_oid = oid + return session.exec(select(CoreDatasource).where(CoreDatasource.oid == current_oid).order_by( + func.convert_to(CoreDatasource.name, 'gbk'))).all() + def get_ds(session: SessionDep, id: int): @@ -134,7 +137,7 @@ def getTables(session: SessionDep, id: int): def getTablesByDs(session: SessionDep, ds: CoreDatasource): - check_status(session, ds, True) + # check_status(session, ds, True) tables = get_tables(ds) return tables @@ -241,7 +244,7 @@ def updateField(session: SessionDep, field: CoreField): def preview(session: SessionDep, current_user: CurrentUser, id: int, data: TableObj): ds = session.query(CoreDatasource).filter(CoreDatasource.id == id).first() - check_status(session, ds, True) + # check_status(session, ds, True) if data.fields is None or len(data.fields) == 0: return {"fields": [], "data": [], "sql": ''} @@ -278,8 +281,8 @@ def preview(session: SessionDep, current_user: CurrentUser, id: int, data: Table ).first() if obj is not None: res.append(transRecord2DTO(session, permission)) - wheres = transFilterTree(session, res, ds) - where = (' where ' + wheres) if wheres is not None and wheres != '' else '' + where_str = transFilterTree(session, res, ds) + where = (' where ' + where_str) if where_str is not None and where_str != '' else '' fields = [f.field_name for f in f_list] if fields is None or len(fields) == 0: diff --git a/backend/apps/db/db.py b/backend/apps/db/db.py index 2c00bcaf..b051d787 100644 --- a/backend/apps/db/db.py +++ b/backend/apps/db/db.py @@ -254,20 +254,17 @@ def get_fields(ds: CoreDatasource, table_name: str = None): def exec_sql(ds: CoreDatasource | AssistantOutDsSchema, sql: str): - session = get_session(ds) - result = session.execute(text(sql)) - try: - columns = result.keys()._keys - res = result.fetchall() - result_list = [ - {columns[i]: float(value) if isinstance(value, Decimal) else value for i, value in enumerate(tuple_item)} - for tuple_item in res - ] - return {"fields": columns, "data": result_list, "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} - except Exception as ex: - raise ex - finally: - if result is not None: - result.close() - if session is not None: - session.close() + with get_session(ds) as session: + with session.execute(text(sql)) as result: + try: + columns = result.keys()._keys + res = result.fetchall() + result_list = [ + {columns[i]: float(value) if isinstance(value, Decimal) else value for i, value in + enumerate(tuple_item)} + for tuple_item in res + ] + return {"fields": columns, "data": result_list, + "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} + except Exception as ex: + raise ex diff --git a/backend/apps/system/api/aimodel.py b/backend/apps/system/api/aimodel.py index 781d1889..eece4367 100644 --- a/backend/apps/system/api/aimodel.py +++ b/backend/apps/system/api/aimodel.py @@ -1,15 +1,35 @@ import json from typing import List, Union +from apps.ai_model.model_factory import LLMConfig, LLMFactory from apps.system.schemas.ai_model_schema import AiModelConfigItem, AiModelCreator, AiModelEditor, AiModelGridItem from fastapi import APIRouter, Query from sqlmodel import func, select, update from apps.system.models.system_model import AiModelDetail -from common.core.deps import SessionDep +from common.core.deps import SessionDep, Trans from common.utils.time import get_timestamp +from common.utils.utils import SQLBotLogUtil router = APIRouter(tags=["system/aimodel"], prefix="/system/aimodel") +@router.post("/status") +async def check_llm(info: AiModelCreator, trans: Trans): + try: + additional_params = {item.key: item.val for item in info.config_list} + config = LLMConfig( + model_type="openai", + model_name=info.base_model, + api_key=info.api_key, + api_base_url=info.api_domain, + additional_params=additional_params, + ) + llm_instance = LLMFactory.create_llm(config) + result = llm_instance.llm.invoke("who are you?") + SQLBotLogUtil.info(f"check_llm result: {result}") + except Exception as e: + SQLBotLogUtil.error(f"Error checking LLM: {e}") + raise Exception(trans('i18n_llm.validate_error', msg = str(e))) + @router.get("", response_model=list[AiModelGridItem]) async def query( session: SessionDep, @@ -74,19 +94,20 @@ async def update_model( data["config"] = json.dumps([item.model_dump(exclude_unset=True) for item in editor.config_list]) data.pop("config_list", None) db_model = session.get(AiModelDetail, id) - update_data = AiModelDetail.model_validate(data) - db_model.sqlmodel_update(update_data) + #update_data = AiModelDetail.model_validate(data) + db_model.sqlmodel_update(data) session.add(db_model) session.commit() @router.delete("/{id}") async def delete_model( session: SessionDep, + trans: Trans, id: int ): item = session.get(AiModelDetail, id) if item.default_model: - raise RuntimeError(f"Can not delete [${item.name}], because it is default model!") + raise Exception(trans('i18n_llm.delete_default_error', key = item.name)) session.delete(item) session.commit() diff --git a/backend/apps/system/api/login.py b/backend/apps/system/api/login.py index 0aca2934..8e360e08 100644 --- a/backend/apps/system/api/login.py +++ b/backend/apps/system/api/login.py @@ -2,7 +2,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException from fastapi.security import OAuth2PasswordRequestForm from apps.system.schemas.system_schema import BaseUserDTO -from common.core.deps import SessionDep +from common.core.deps import SessionDep, Trans from ..crud.user import authenticate from common.core.security import create_access_token from datetime import timedelta @@ -13,14 +13,16 @@ router = APIRouter(tags=["login"], prefix="/login") @router.post("/access-token") def local_login( session: SessionDep, + trans: Trans, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] ) -> Token: user: BaseUserDTO = authenticate(session=session, account=form_data.username, password=form_data.password) if not user: - raise HTTPException(status_code=400, detail="Incorrect account or password") - + raise HTTPException(status_code=400, detail=trans('i18n_login.account_pwd_error')) if not user.oid or user.oid == 0: - raise HTTPException(status_code=400, detail="No associated workspace, Please contact the administrator") + raise HTTPException(status_code=400, detail=trans('i18n_login.no_associated_ws', msg = trans('i18n_concat_admin'))) + if user.status != 1: + raise HTTPException(status_code=400, detail=trans('i18n_login.user_disable', msg = trans('i18n_concat_admin'))) access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) user_dict = user.to_dict() return Token(access_token=create_access_token( diff --git a/backend/apps/system/api/user.py b/backend/apps/system/api/user.py index cb4e9fdb..9d9644cd 100644 --- a/backend/apps/system/api/user.py +++ b/backend/apps/system/api/user.py @@ -1,11 +1,11 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Query from sqlmodel import func, or_, select, delete as sqlmodel_delete -from apps.system.crud.user import get_db_user, single_delete, user_ws_options -from apps.system.models.system_model import UserWsModel +from apps.system.crud.user import check_account_exists, check_email_exists, check_email_format, check_pwd_format, get_db_user, single_delete, user_ws_options +from apps.system.models.system_model import UserWsModel, WorkspaceModel from apps.system.models.user import UserModel from apps.system.schemas.auth import CacheName, CacheNamespace -from apps.system.schemas.system_schema import PwdEditor, UserCreator, UserEditor, UserGrid, UserLanguage, UserWs +from apps.system.schemas.system_schema import PwdEditor, UserCreator, UserEditor, UserGrid, UserLanguage, UserStatus, UserWs from common.core.deps import CurrentUser, SessionDep, Trans from common.core.pagination import Paginator from common.core.schemas import PaginatedResponse, PaginationParams @@ -96,10 +96,13 @@ async def ws_options(session: SessionDep, current_user: CurrentUser, trans: Tran @router.put("/ws/{oid}") @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="current_user.id") -async def ws_change(session: SessionDep, current_user: CurrentUser, oid: int): +async def ws_change(session: SessionDep, current_user: CurrentUser, trans:Trans, oid: int): ws_list: list[UserWs] = await user_ws_options(session, current_user.id) if not any(x.id == oid for x in ws_list): - raise HTTPException(f"oid [{oid}] is invalid!") + db_ws = session.get(WorkspaceModel, oid) + if db_ws: + raise Exception(trans('i18n_user.ws_miss', ws = db_ws.name)) + raise Exception(trans('i18n_not_exist', msg = f"{trans('i18n_ws.title')}[{oid}]")) user_model: UserModel = get_db_user(session = session, user_id = current_user.id) user_model.oid = oid session.add(user_model) @@ -115,7 +118,13 @@ async def query(session: SessionDep, trans: Trans, id: int) -> UserEditor: return result @router.post("") -async def create(session: SessionDep, creator: UserCreator): +async def create(session: SessionDep, creator: UserCreator, trans: Trans): + if check_account_exists(session=session, account=creator.account): + raise Exception(trans('i18n_exist', msg = f"{trans('i18n_user.account')} [{creator.account}]")) + if check_email_exists(session=session, email=creator.email): + raise Exception(trans('i18n_exist', msg = f"{trans('i18n_user.email')} [{creator.email}]")) + if not check_email_format(creator.email): + raise Exception(trans('i18n_format_invalid', key = f"{trans('i18n_user.email')} [{creator.email}]")) data = creator.model_dump(exclude_unset=True) user_model = UserModel.model_validate(data) #user_model.create_time = get_timestamp() @@ -138,8 +147,16 @@ async def create(session: SessionDep, creator: UserCreator): @router.put("") @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="editor.id") -async def update(session: SessionDep, editor: UserEditor): +async def update(session: SessionDep, editor: UserEditor, trans: Trans): user_model: UserModel = get_db_user(session = session, user_id = editor.id) + if not user_model: + raise Exception(f"User with id [{editor.id}] not found!") + if editor.account != user_model.account: + raise Exception(f"account cannot be changed!") + if editor.email != user_model.email and check_email_exists(session=session, account=editor.email): + raise Exception(trans('i18n_exist', msg = f"{trans('i18n_user.email')} [{editor.email}]")) + if not check_email_format(editor.email): + raise Exception(trans('i18n_format_invalid', key = f"{trans('i18n_user.email')} [{editor.email}]")) origin_oid: int = user_model.oid del_stmt = sqlmodel_delete(UserWsModel).where(UserWsModel.uid == editor.id) session.exec(del_stmt) @@ -174,10 +191,10 @@ async def batch_del(session: SessionDep, id_list: list[int]): @router.put("/language") @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="current_user.id") -async def langChange(session: SessionDep, current_user: CurrentUser, language: UserLanguage): +async def langChange(session: SessionDep, current_user: CurrentUser, trans: Trans, language: UserLanguage): lang = language.language if lang not in ["zh-CN", "en"]: - return {"message": "Language not supported"} + raise Exception(trans('i18n_user.language_not_support', key = lang)) db_user: UserModel = get_db_user(session=session, user_id=current_user.id) db_user.language = lang session.add(db_user) @@ -185,9 +202,9 @@ async def langChange(session: SessionDep, current_user: CurrentUser, language: U @router.patch("/pwd/{id}") @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="id") -async def pwdReset(session: SessionDep, current_user: CurrentUser, id: int): +async def pwdReset(session: SessionDep, current_user: CurrentUser, trans: Trans, id: int): if not current_user.isAdmin: - raise HTTPException('only for admin') + raise Exception(trans('i18n_permission.no_permission', url = " patch[/user/pwd/id],", msg = trans('i18n_permission.only_admin'))) db_user: UserModel = get_db_user(session=session, user_id=id) db_user.password = default_md5_pwd() session.add(db_user) @@ -195,10 +212,26 @@ async def pwdReset(session: SessionDep, current_user: CurrentUser, id: int): @router.put("/pwd") @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="current_user.id") -async def pwdUpdate(session: SessionDep, current_user: CurrentUser, editor: PwdEditor): +async def pwdUpdate(session: SessionDep, current_user: CurrentUser, trans: Trans, editor: PwdEditor): + new_pwd = editor.new_pwd + if not check_pwd_format(new_pwd): + raise Exception(trans('i18n_format_invalid', key = trans('i18n_user.password'))) db_user: UserModel = get_db_user(session=session, user_id=current_user.id) if not verify_md5pwd(editor.pwd, db_user.password): - raise HTTPException("pwd error") - db_user.password = md5pwd(editor.new_pwd) + raise Exception(trans('i18n_error', key = trans('i18n_user.password'))) + db_user.password = md5pwd(new_pwd) + session.add(db_user) + session.commit() + +@router.patch("/status") +@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="statusDto.id") +async def langChange(session: SessionDep, current_user: CurrentUser, trans: Trans, statusDto: UserStatus): + if not current_user.isAdmin: + raise Exception(trans('i18n_permission.no_permission', url = ", ", msg = trans('i18n_permission.only_admin'))) + status = statusDto.status + if status not in [0, 1]: + return {"message": "status not supported"} + db_user: UserModel = get_db_user(session=session, user_id=statusDto.id) + db_user.status = status session.add(db_user) session.commit() \ No newline at end of file diff --git a/backend/apps/system/api/workspace.py b/backend/apps/system/api/workspace.py index 5201200d..893333ca 100644 --- a/backend/apps/system/api/workspace.py +++ b/backend/apps/system/api/workspace.py @@ -17,15 +17,16 @@ router = APIRouter(tags=["system/workspace"], prefix="/system/workspace") async def option_pager( session: SessionDep, current_user: CurrentUser, + trans: Trans, pageNum: int, pageSize: int, oid: int = Query(description="空间ID"), keyword: Optional[str] = Query(None, description="搜索关键字(可选)"), ): if not current_user.isAdmin: - raise RuntimeError('only for admin') + raise Exception(trans('i18n_permission.no_permission', url = ", ", msg = trans('i18n_permission.only_admin'))) if not oid: - raise RuntimeError('oid miss error') + raise Exception(trans('i18n_miss_args', key = '[oid]')) pagination = PaginationParams(page=pageNum, size=pageSize) paginator = Paginator(session) stmt = select(UserModel.id, UserModel.account, UserModel.name).where( @@ -50,12 +51,13 @@ async def option_pager( async def option_user( session: SessionDep, current_user: CurrentUser, + trans: Trans, keyword: str = Query(description="搜索关键字") ): if not keyword: - raise HTTPException("keyword is required") + raise Exception(trans('i18n_miss_args', key = '[keyword]')) if (not current_user.isAdmin) and current_user.weight == 0: - raise RuntimeError("no permission to execute this api") + raise Exception(trans('i18n_permission.no_permission', url = '', msg = '')) oid = current_user.oid stmt = select(UserModel.id, UserModel.account, UserModel.name).where( @@ -76,13 +78,14 @@ async def option_user( async def pager( session: SessionDep, current_user: CurrentUser, + trans: Trans, pageNum: int, pageSize: int, keyword: Optional[str] = Query(None, description="搜索关键字(可选)"), oid: Optional[int] = Query(None, description="空间ID(仅admin用户生效)"), ): if not current_user.isAdmin and current_user.weight == 0: - raise HTTPException("no permission to execute") + raise Exception(trans('i18n_permission.no_permission', url = '', msg = '')) if current_user.isAdmin: workspace_id = oid if oid else current_user.oid else: @@ -112,9 +115,9 @@ async def pager( @router.post("/uws") -async def create(session: SessionDep, current_user: CurrentUser, creator: UserWsDTO): +async def create(session: SessionDep, current_user: CurrentUser, trans: Trans, creator: UserWsDTO): if not current_user.isAdmin and current_user.weight == 0: - raise HTTPException("no permission to execute") + raise Exception(trans('i18n_permission.no_permission', url = '', msg = '')) oid: int = creator.oid if (current_user.isAdmin and creator.oid) else current_user.oid weight = creator.weight if (current_user.isAdmin and creator.weight) else 0 # 判断uid_list以及oid合法性 @@ -134,9 +137,9 @@ async def create(session: SessionDep, current_user: CurrentUser, creator: UserWs session.commit() @router.put("/uws") -async def edit(session: SessionDep, editor: UserWsEditor): +async def edit(session: SessionDep, trans: Trans, editor: UserWsEditor): if not editor.oid or not editor.uid: - raise HTTPException("param [oid, uid] miss") + raise Exception(trans('i18n_miss_args', key = '[oid, uid]')) db_model = session.exec(select(UserWsModel).where(UserWsModel.uid == editor.uid, UserWsModel.oid == editor.oid)).first() if not db_model: raise HTTPException("uws not exist") @@ -150,9 +153,9 @@ async def edit(session: SessionDep, editor: UserWsEditor): session.commit() @router.delete("/uws") -async def delete(session: SessionDep, current_user: CurrentUser, dto: UserWsBase): +async def delete(session: SessionDep, current_user: CurrentUser, trans: Trans, dto: UserWsBase): if not current_user.isAdmin and current_user.weight == 0: - raise HTTPException("no permission to execute") + raise Exception(trans('i18n_permission.no_permission', url = '', msg = '')) oid: int = dto.oid if (current_user.isAdmin and dto.oid) else current_user.oid db_model_list: list[UserWsModel] = session.exec(select(UserWsModel).where(UserWsModel.uid.in_(dto.uid_list), UserWsModel.oid == oid)).all() if not db_model_list: diff --git a/backend/apps/system/crud/assistant.py b/backend/apps/system/crud/assistant.py index fddf2247..76c8c3bf 100644 --- a/backend/apps/system/crud/assistant.py +++ b/backend/apps/system/crud/assistant.py @@ -33,11 +33,12 @@ def get_assistant_ds(llm_service) -> list[dict]: configuration = assistant.configuration if configuration: config: dict[any] = json.loads(configuration) - oid: str = config['oid'] + oid: int = int(config['oid']) stmt = select(CoreDatasource.id, CoreDatasource.name, CoreDatasource.description).where(CoreDatasource.oid == oid) - private_list:list[int] = config.get('private_list') or None - if private_list: - stmt.where(~CoreDatasource.id.in_(private_list)) + if not assistant.online: + private_list:list[int] = config.get('private_list') or None + if private_list: + stmt = stmt.where(~CoreDatasource.id.in_(private_list)) db_ds_list = session.exec(stmt) result_list = [ @@ -131,7 +132,7 @@ class AssistantOutDs: def get_db_schema(self, ds_id: int) -> str: ds = self.get_ds(ds_id) schema_str = "" - db_name = ds.schema + db_name = ds.db_schema schema_str += f"【DB_ID】 {db_name}\n【Schema】\n" for table in ds.tables: schema_str += f"# Table: {db_name}.{table.name}" @@ -159,7 +160,9 @@ class AssistantOutDs: if attr in ds_dict: id_marker += str(ds_dict.get(attr, '')) + '--sqlbot--' id = string_to_numeric_hash(id_marker) - return AssistantOutDsSchema(**{**ds_dict, "id": id}) + db_schema = ds_dict.get('schema', ds_dict.get('db_schema', '')) + ds_dict.pop("schema", None) + return AssistantOutDsSchema(**{**ds_dict, "id": id, "db_schema": db_schema}) class AssistantOutDsFactory: @staticmethod @@ -177,12 +180,12 @@ def get_ds_engine(ds: AssistantOutDsSchema) -> Engine: database=ds.dataBase, driver='', extraJdbc=ds.extraParams, - dbSchema=ds.schema or '' + dbSchema=ds.db_schema or '' ) from apps.db.db import get_uri_from_config uri = get_uri_from_config(ds.type, conf) - if ds.type == "pg" and ds.schema: - connect_args.update({"options": f"-c search_path={ds.schema}"}) + if ds.type == "pg" and ds.db_schema: + connect_args.update({"options": f"-c search_path={ds.db_schema}"}) engine = create_engine(uri, connect_args=connect_args, pool_timeout=timeout, pool_size=20, max_overflow=10) return engine \ No newline at end of file diff --git a/backend/apps/system/crud/user.py b/backend/apps/system/crud/user.py index fa0166e8..d721ef50 100644 --- a/backend/apps/system/crud/user.py +++ b/backend/apps/system/crud/user.py @@ -1,15 +1,16 @@ from typing import Optional -from sqlmodel import Session, select, delete as sqlmodel_delete +from sqlmodel import Session, func, select, delete as sqlmodel_delete from apps.system.models.system_model import UserWsModel, WorkspaceModel from apps.system.schemas.auth import CacheName, CacheNamespace -from apps.system.schemas.system_schema import BaseUserDTO, UserInfoDTO, UserWs +from apps.system.schemas.system_schema import EMAIL_REGEX, PWD_REGEX, BaseUserDTO, UserInfoDTO, UserWs from common.core.deps import SessionDep from common.core.sqlbot_cache import cache, clear_cache from common.utils.locale import I18n from common.utils.utils import SQLBotLogUtil from ..models.user import UserModel from common.core.security import verify_md5pwd +import re def get_db_user(*, session: Session, user_id: int) -> UserModel: db_user = session.get(UserModel, user_id) @@ -69,3 +70,17 @@ async def single_delete(session: SessionDep, id: int): @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="id") async def clean_user_cache(id: int): SQLBotLogUtil.info(f"User cache for [{id}] has been cleaned") + + +def check_account_exists(*, session: Session, account: str) -> bool: + return session.exec(select(func.count()).select_from(UserModel).where(UserModel.account == account)).one() > 0 +def check_email_exists(*, session: Session, email: str) -> bool: + return session.exec(select(func.count()).select_from(UserModel).where(UserModel.email == email)).one() > 0 + + + +def check_email_format(email: str) -> bool: + return bool(EMAIL_REGEX.fullmatch(email)) + +def check_pwd_format(pwd: str) -> bool: + return bool(PWD_REGEX.fullmatch(pwd)) diff --git a/backend/apps/system/middleware/auth.py b/backend/apps/system/middleware/auth.py index 23bdd512..4f4d8706 100644 --- a/backend/apps/system/middleware/auth.py +++ b/backend/apps/system/middleware/auth.py @@ -36,7 +36,7 @@ class TokenMiddleware(BaseHTTPMiddleware): request.state.current_user = validator[1] request.state.assistant = validator[2] return await call_next(request) - return JSONResponse({"msg": f"Unauthorized:[{validator[1]}]"}, status_code=401, headers={"Access-Control-Allow-Origin": "*"}) + return JSONResponse(f"Unauthorized:[{validator[1]}]", status_code=401, headers={"Access-Control-Allow-Origin": "*"}) #validate pass tokenkey = settings.TOKEN_KEY token = request.headers.get(tokenkey) @@ -44,7 +44,7 @@ class TokenMiddleware(BaseHTTPMiddleware): if validate_pass: request.state.current_user = data return await call_next(request) - return JSONResponse({"msg": f"Unauthorized:[{data}]"}, status_code=401, headers={"Access-Control-Allow-Origin": "*"}) + return JSONResponse(f"Unauthorized:[{data}]", status_code=401, headers={"Access-Control-Allow-Origin": "*"}) def is_options(self, request: Request): return request.method == "OPTIONS" @@ -62,7 +62,13 @@ class TokenMiddleware(BaseHTTPMiddleware): try: with Session(engine) as session: session_user = await get_user_info(session = session, user_id = token_data.id) + if not session_user: + raise Exception(f"User not found with id: {token_data.id}") session_user = UserInfoDTO.model_validate(session_user) + if session_user.status != 1: + raise Exception(f"User is not active!") + if not session_user.oid or session_user.oid == 0: + raise Exception(f"User default space is not set!") """ if token_data.oid != session_user.oid: raise HTTPException( status_code=401, diff --git a/backend/apps/system/schemas/system_schema.py b/backend/apps/system/schemas/system_schema.py index 3d2ab410..7ea29e67 100644 --- a/backend/apps/system/schemas/system_schema.py +++ b/backend/apps/system/schemas/system_schema.py @@ -1,11 +1,22 @@ from typing import Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator from common.core.schemas import BaseCreatorDTO +import re -class model_status(BaseModel): - status: bool - ids: list[int] +EMAIL_REGEX = re.compile( + r"^[a-zA-Z0-9]+([._-][a-zA-Z0-9]+)*@" + r"([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+" + r"[a-zA-Z]{2,}$" +) +PWD_REGEX = re.compile( + r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)" + r"(?=.*[~!@#$%^&*()_+\-={}|:\"<>?`\[\];',./])" + r"[A-Za-z\d~!@#$%^&*()_+\-={}|:\"<>?`\[\];',./]{8,20}$" +) +class UserStatus(BaseCreatorDTO): + status: int = 1 + class UserLanguage(BaseModel): @@ -13,24 +24,36 @@ class UserLanguage(BaseModel): class BaseUser(BaseModel): - account: str + account: str = Field(min_length=1, max_length=100, description="用户账号") oid: int - + class BaseUserDTO(BaseUser, BaseCreatorDTO): - language: str + language: str = Field(pattern=r"^(zh-CN|en)$", default="zh-CN", description="用户语言") password: str + status: int = 1 def to_dict(self): return { "id": self.id, "account": self.account, "oid": self.oid } + @field_validator("language") + def validate_language(cls, lang: str) -> str: + if not re.fullmatch(r"^(zh-CN|en)$", lang): + raise ValueError("Language must be 'zh-CN' or 'en'") + return lang class UserCreator(BaseUser): - name: str - email: str + name: str = Field(min_length=1, max_length=100, description="用户名") + email: str = Field(min_length=1, max_length=100, description="用户邮箱") status: int = 1 oid_list: Optional[list[int]] = None + + """ @field_validator("email") + def validate_email(cls, lang: str) -> str: + if not re.fullmatch(EMAIL_REGEX, lang): + raise ValueError("Email format is invalid!") + return lang """ class UserEditor(UserCreator, BaseCreatorDTO): pass @@ -139,7 +162,7 @@ class AssistantOutDsSchema(AssistantOutDsBase): dataBase: Optional[str] = None user: Optional[str] = None password: Optional[str] = None - schema: Optional[str] = None + db_schema: Optional[str] = None extraParams: Optional[str] = None tables: Optional[list[AssistantTableSchema]] = None \ No newline at end of file diff --git a/backend/common/core/deps.py b/backend/common/core/deps.py index afc2dc36..0dcef442 100644 --- a/backend/common/core/deps.py +++ b/backend/common/core/deps.py @@ -25,6 +25,9 @@ async def get_current_assistant(request: Request) -> AssistantHeader | None: if request.headers.get("X-SQLBOT-ASSISTANT-CERTIFICATE"): entry_certificate = request.headers['X-SQLBOT-ASSISTANT-CERTIFICATE'] base_assistant.certificate = unquote(base64.b64decode(entry_certificate).decode('utf-8')) + if request.headers.get("X-SQLBOT-ASSISTANT-ONLINE"): + entry_online = request.headers['X-SQLBOT-ASSISTANT-ONLINE'] + base_assistant.online = str(entry_online).lower() == 'true' if entry_online else False return base_assistant CurrentAssistant = Annotated[AssistantHeader, Depends(get_current_assistant)] diff --git a/backend/common/core/response_middleware.py b/backend/common/core/response_middleware.py index e1dbab9b..6842893e 100644 --- a/backend/common/core/response_middleware.py +++ b/backend/common/core/response_middleware.py @@ -14,7 +14,8 @@ class ResponseMiddleware(BaseHTTPMiddleware): if isinstance(response, JSONResponse) or request.url.path == f"{settings.API_V1_STR}/openapi.json": return response - + if response.status_code != 200: + return response if response.headers.get("content-type") == "application/json": try: body = b"" @@ -51,11 +52,7 @@ class ResponseMiddleware(BaseHTTPMiddleware): SQLBotLogUtil.error(f"Response processing error: {str(e)}", exc_info=True) return JSONResponse( status_code=500, - content={ - "code": 500, - "data": None, - "msg": str(e) - }, + content=str(e), headers={ k: v for k, v in response.headers.items() if k.lower() not in ("content-length", "content-type") @@ -71,11 +68,7 @@ class exception_handler(): SQLBotLogUtil.error(f"HTTP Exception: {exc.detail}", exc_info=True) return JSONResponse( status_code=exc.status_code, - content={ - "code": exc.status_code, - "msg": exc.detail, - "data": None - }, + content=exc.detail, headers={"Access-Control-Allow-Origin": "*"} ) @@ -85,11 +78,7 @@ class exception_handler(): SQLBotLogUtil.error(f"Unhandled Exception: {str(exc)}", exc_info=True) return JSONResponse( status_code=500, - content={ - "code": 500, - "msg": str(exc), - "data": None - }, + content=str(exc), headers={"Access-Control-Allow-Origin": "*"} ) diff --git a/backend/common/utils/locale.py b/backend/common/utils/locale.py index 32d9009d..67a0d97c 100644 --- a/backend/common/utils/locale.py +++ b/backend/common/utils/locale.py @@ -1,16 +1,15 @@ from pathlib import Path import json -from typing import Dict, Optional +from typing import Dict, Optional, Any from fastapi import Request class I18n: def __init__(self, locale_dir: str = "locales"): self.locale_dir = Path(locale_dir) - self.translations: Dict[str, Dict[str, str]] = {} + self.translations: Dict[str, Dict[str, Any]] = {} self.load_translations() def load_translations(self): - if not self.locale_dir.exists(): self.locale_dir.mkdir() return @@ -34,9 +33,21 @@ class I18nHelper: self.request = request self.lang = i18n.get_language(request) - def __call__(self, key: str, **kwargs) -> str: + def _get_nested_translation(self, data: Dict[str, Any], key_path: str) -> str: + keys = key_path.split('.') + current = data + + for key in keys: + if isinstance(current, dict) and key in current: + current = current[key] + else: + return key_path # 如果找不到,返回原键 + + return current if isinstance(current, str) else key_path + + def __call__(self, arg_key: str, **kwargs) -> str: lang_data = self.i18n.translations.get(self.lang, {}) - text = lang_data.get(key, key) # 找不到则返回原key + text = self._get_nested_translation(lang_data, arg_key) if kwargs: try: diff --git a/backend/locales/en.json b/backend/locales/en.json index 1bb123f7..7b775419 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1,4 +1,33 @@ { "i18n_default_workspace": "Default workspace", - "i18n_ds_name_exist": "Name exist" + "i18n_ds_name_exist": "Name already exists", + "i18n_concat_admin": "Please contact administrator!", + "i18n_exist": "{msg} already exists!", + "i18n_not_exist": "{msg} not exists", + "i18n_error": "{key} error!", + "i18n_miss_args": "Missing {key} parameter!", + "i18n_format_invalid": "{key} format is incorrect!", + "i18n_login": { + "account_pwd_error": "Account or password error!", + "no_associated_ws": "No associated workspace, {msg}", + "user_disable": "Account disabled, {msg}" + }, + "i18n_user": { + "account": "Account", + "email": "Email", + "password": "Password", + "language_not_support": "System does not support [{key}] language!", + "ws_miss": "The current user is not in the workspace [{ws}]!" + }, + "i18n_ws": { + "title": "Workspace" + }, + "i18n_permission": { + "only_admin": "Only administrators can call this!", + "no_permission": "No permission to access {url}{msg}" + }, + "i18n_llm": { + "validate_error": "Validation failed [{msg}]", + "delete_default_error": "Cannot delete default model [{key}]!" + } } \ No newline at end of file diff --git a/backend/locales/zh-CN.json b/backend/locales/zh-CN.json index 8a9f8c2a..abadd609 100644 --- a/backend/locales/zh-CN.json +++ b/backend/locales/zh-CN.json @@ -1,4 +1,34 @@ { "i18n_default_workspace": "默认工作空间", - "i18n_ds_name_exist": "名称已存在" + "i18n_ds_name_exist": "名称已存在", + "i18n_concat_admin": "请联系管理员!", + "i18n_exist": "{msg}已存在!", + "i18n_not_exist": "{msg}不存在!", + "i18n_error": "{key}错误!", + "i18n_miss_args": "缺失{key}参数!", + "i18n_format_invalid": "{key}格式不正确!", + "i18n_login": { + "account_pwd_error": "账号或密码错误!", + "no_associated_ws": "没有关联的工作空间,{msg}", + "user_disable": "账号已禁用,{msg}" + }, + "i18n_user": { + "account": "账号", + "email": "邮箱", + "password": "密码", + "language_not_support": "系统不支持[{key}]语言!", + "ws_miss": "当前用户不在工作空间[{ws}]中!" + }, + "i18n_ws": { + "title": "工作空间" + }, + "i18n_permission": { + "only_admin": "仅支持管理员调用!", + "no_permission": "无权调用{url}{msg}" + + }, + "i18n_llm": { + "validate_error": "校验失败[{msg}]", + "delete_default_error": "无法删除默认模型[{key}]!" + } } \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1de57be8..3e4a7c71 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "pyyaml (>=6.0.2,<7.0.0)", "fastapi-mcp (>=0.3.4,<0.4.0)", "tabulate>=0.9.0", - "sqlbot-xpack==0.0.3.7", + "sqlbot-xpack==0.0.3.8", "fastapi-cache2>=0.2.2", "sqlparse>=0.5.3", "redis>=6.2.0", diff --git a/frontend/public/assistant.js b/frontend/public/assistant.js index c7677d65..3485b3d4 100644 --- a/frontend/public/assistant.js +++ b/frontend/public/assistant.js @@ -1,4 +1,5 @@ ;(function () { + window.sqlbot_assistant_handler = window.sqlbot_assistant_handler || {} const defaultData = { id: '1', show_guide: false, @@ -34,14 +35,41 @@ const chatButtonHtml = (data) => `
` const getChatContainerHtml = (data) => { return `