fix(myapp): 调整权限验证为myapp相关权限

- 修改应用详情、列表、创建、更新、删除及状态修改接口的权限从system改为myapp

fix(resource): 修复资源服务中文件URL生成逻辑

- 重新实现URL拼接逻辑,避免多余的斜杠及错误的协议前缀
- 确保base_url
This commit is contained in:
zhangtao
2025-09-10 02:46:41 +08:00
parent b8c8e7af4c
commit 6bd474eb4f
9 changed files with 147 additions and 200 deletions
@@ -24,7 +24,7 @@ MyAppRouter = APIRouter(route_class=OperationLogRoute, prefix="/application", ta
@MyAppRouter.get("/detail/{id}", summary="获取应用详情", description="获取应用详情")
async def get_obj_detail_controller(
id: int = Path(..., description="应用ID"),
auth: AuthSchema = Depends(AuthPermission(permissions=["application:system:query"]))
auth: AuthSchema = Depends(AuthPermission(permissions=["application:myapp:query"]))
) -> JSONResponse:
result_dict = await ApplicationService.get_application_detail_service(id=id, auth=auth)
logger.info(f"获取应用详情成功 {id}")
@@ -34,7 +34,7 @@ async def get_obj_detail_controller(
async def get_obj_list_controller(
page: PaginationQueryParams = Depends(),
search: ApplicationQueryParams = Depends(),
auth: AuthSchema = Depends(AuthPermission(permissions=["application:system:query"]))
auth: AuthSchema = Depends(AuthPermission(permissions=["application:myapp:query"]))
) -> JSONResponse:
result_dict_list = await ApplicationService.get_application_list_service(auth=auth, search=search, order_by=page.order_by)
result_dict = await PaginationService.get_page_obj(data_list=result_dict_list, page_no=page.page_no, page_size=page.page_size)
@@ -44,7 +44,7 @@ async def get_obj_list_controller(
@MyAppRouter.post("/create", summary="创建应用", description="创建应用")
async def create_obj_controller(
data: ApplicationCreateSchema,
auth: AuthSchema = Depends(AuthPermission(permissions=["application:system:create"]))
auth: AuthSchema = Depends(AuthPermission(permissions=["application:myapp:create"]))
) -> JSONResponse:
result_dict = await ApplicationService.create_application_service(auth=auth, data=data)
logger.info(f"创建应用成功: {result_dict}")
@@ -54,7 +54,7 @@ async def create_obj_controller(
async def update_obj_controller(
data: ApplicationUpdateSchema,
id: int = Path(..., description="应用ID"),
auth: AuthSchema = Depends(AuthPermission(permissions=["application:system:update"]))
auth: AuthSchema = Depends(AuthPermission(permissions=["application:myapp:update"]))
) -> JSONResponse:
result_dict = await ApplicationService.update_application_service(auth=auth, id=id, data=data)
logger.info(f"修改应用成功: {result_dict}")
@@ -63,7 +63,7 @@ async def update_obj_controller(
@MyAppRouter.delete("/delete", summary="删除应用", description="删除应用")
async def delete_obj_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(permissions=["application:system:delete"]))
auth: AuthSchema = Depends(AuthPermission(permissions=["application:myapp:delete"]))
) -> JSONResponse:
await ApplicationService.delete_application_service(auth=auth, ids=ids)
logger.info(f"删除应用成功: {ids}")
@@ -72,7 +72,7 @@ async def delete_obj_controller(
@MyAppRouter.patch("/available/setting", summary="批量修改应用状态", description="批量修改应用状态")
async def batch_set_available_obj_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(permissions=["application:system:patch"]))
auth: AuthSchema = Depends(AuthPermission(permissions=["application:myapp:patch"]))
) -> JSONResponse:
await ApplicationService.set_application_available_service(auth=auth, data=data)
logger.info(f"批量修改应用状态成功: {data.ids}")
@@ -146,7 +146,13 @@ class ResourceService:
# 生成HTTP URL路径而不是文件系统路径
if base_url:
from urllib.parse import urljoin
http_url = urljoin(base_url.rstrip('/') + '/', f"{settings.STATIC_URL.lstrip('/')}/{relative_path}".lstrip('/')).replace('\\', '/').replace('//', '/')
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
relative_part = relative_path.lstrip('/')
# 手动构建URL而不是使用urljoin,避免双斜杠问题
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
http_url = f"{base_part}{static_part}/{relative_part}".replace('\\', '/').replace('//', '/').replace(':/', '://')
else:
http_url = f"{settings.STATIC_URL}/{relative_path}".replace('\\', '/').replace('//', '/')
@@ -186,7 +192,12 @@ class ResourceService:
# 对于根目录,返回静态URL路径
if base_url:
from urllib.parse import urljoin
display_path = urljoin(base_url.rstrip('/') + '/', settings.STATIC_URL.lstrip('/'))
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
display_path = f"{base_part}{static_part}".replace('//', '/').replace(':/', '://')
else:
display_path = settings.STATIC_URL
else:
@@ -197,13 +208,24 @@ class ResourceService:
relative_path = os.path.relpath(safe_path, resource_root)
if base_url:
from urllib.parse import urljoin
display_path = urljoin(base_url.rstrip('/') + '/', f"{settings.STATIC_URL.lstrip('/')}/{relative_path}".lstrip('/')).replace('\\', '/').replace('//', '/')
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
relative_part = relative_path.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
display_path = f"{base_part}{static_part}/{relative_part}".replace('\\', '/').replace('//', '/').replace(':/', '://')
else:
display_path = f"{settings.STATIC_URL}/{relative_path}".replace('\\', '/').replace('//', '/')
except ValueError:
if base_url:
from urllib.parse import urljoin
display_path = urljoin(base_url.rstrip('/') + '/', settings.STATIC_URL.lstrip('/'))
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
display_path = f"{base_part}{static_part}".replace('//', '/').replace(':/', '://')
else:
display_path = settings.STATIC_URL
@@ -466,14 +488,27 @@ class ResourceService:
# 如果提供了base_url,使用它生成完整URL,否则使用settings.STATIC_URL
if base_url:
from urllib.parse import urljoin
file_url = urljoin(base_url.rstrip('/') + '/', f"{settings.STATIC_URL.lstrip('/')}/{file_url_path}".lstrip('/'))
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
file_url_part = file_url_path.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
file_url = f"{base_part}{static_part}/{file_url_part}".replace('//', '/').replace(':/', '://')
else:
file_url = f"{settings.STATIC_URL}/{file_url_path}".replace('//', '/')
except ValueError:
# 如果无法计算相对路径,使用文件名
filename = os.path.basename(file_path)
if base_url:
from urllib.parse import urljoin
file_url = urljoin(base_url.rstrip('/') + '/', f"{settings.STATIC_URL.lstrip('/')}/{filename}".lstrip('/'))
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
filename_part = filename.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
file_url = f"{base_part}{static_part}/{filename_part}".replace('//', '/').replace(':/', '://')
else:
file_url = f"{settings.STATIC_URL}/{filename}"
@@ -511,7 +546,13 @@ class ResourceService:
# 生成HTTP URL
if base_url:
from urllib.parse import urljoin
http_url = urljoin(base_url.rstrip('/') + '/', f"{settings.STATIC_URL.lstrip('/')}/{relative_path}".lstrip('/')).replace('\\', '/').replace('//', '/')
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
relative_part = relative_path.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
http_url = f"{base_part}{static_part}/{relative_part}".replace('\\', '/').replace('//', '/').replace(':/', '://')
else:
http_url = f"{settings.STATIC_URL}/{relative_path}".replace('\\', '/').replace('//', '/')
logger.info(f"生成文件访问URL: {http_url}")
@@ -521,7 +562,13 @@ class ResourceService:
filename = os.path.basename(safe_path)
if base_url:
from urllib.parse import urljoin
http_url = urljoin(base_url.rstrip('/') + '/', f"{settings.STATIC_URL.lstrip('/')}/{filename}".lstrip('/'))
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
filename_part = filename.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
http_url = f"{base_part}{static_part}/{filename_part}".replace('//', '/').replace(':/', '://')
else:
http_url = f"{settings.STATIC_URL}/{filename}"
logger.info(f"生成文件访问URL: {http_url}")
+15 -27
View File
@@ -3,7 +3,7 @@
from redis import asyncio as aioredis
from motor.motor_asyncio import AsyncIOMotorClient
from fastapi import FastAPI
from sqlalchemy import create_engine, text
from sqlalchemy import create_engine, text, Engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.asyncio import (
create_async_engine,
@@ -27,11 +27,14 @@ from sqlalchemy.exc import (
from app.core.logger import logger
from app.config.setting import settings
from app.core.exceptions import CustomException
from app.core.base_model import MappedBase
# 同步数据库引擎
engine = create_engine(
engine: Engine = create_engine(
url=settings.DB_URI,
echo=settings.DATABASE_ECHO,
pool_pre_ping=settings.POOL_PRE_PING,
pool_recycle=settings.POOL_RECYCLE,
)
# 同步数据库会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@@ -67,33 +70,18 @@ def session_connect() -> AsyncSession:
except Exception as e:
raise CustomException(msg=f"数据库连接失败: {e}")
async def test_db_connection(session: AsyncSession) -> bool:
async def init_create_table():
"""
应用启动时初始化数据库连接
:return:
"""
try:
# 执行简单查询测试连接是否真实可用
await session.execute(text("SELECT 1"))
return True
except OperationalError as e:
raise CustomException(msg=f"数据库操作失败: {e},请检查数据库服务是否正常运行")
except TimeoutError as e:
raise CustomException(msg=f"数据库连接超时: {e},请检查网络连接或增加连接超时时间")
except DisconnectionError as e:
raise CustomException(msg=f"数据库连接中断: {e},请检查数据库服务状态")
except InterfaceError as e:
raise CustomException(msg=f"数据库接口错误: {e},请检查数据库驱动配置")
except ProgrammingError as e:
raise CustomException(msg=f"SQL语句错误: {e},请检查SQL语法")
except IntegrityError as e:
raise CustomException(msg=f"数据完整性错误: {e},请检查数据约束条件")
except DataError as e:
raise CustomException(msg=f"数据类型错误: {e},请检查数据格式")
except InternalError as e:
raise CustomException(msg=f"数据库内部错误: {e},请联系数据库管理员")
except NotSupportedError as e:
raise CustomException(msg=f"数据库不支持该操作: {e},请检查数据库版本")
except InvalidRequestError as e:
raise CustomException(msg=f"无效的数据库请求: {e},请检查请求参数")
async with async_engine.begin() as conn:
await conn.run_sync(MappedBase.metadata.create_all)
logger.info('数据库连接成功...')
except Exception as e:
raise CustomException(msg=f"数据库操作异常: {e},请联系管理员")
raise CustomException(msg=f"数据库连接失败: {e}")
async def redis_connect(app: FastAPI, status: bool) -> aioredis.Redis:
"""创建或关闭Redis连接"""
+2 -1
View File
@@ -21,7 +21,8 @@ from app.api.v1.module_system.auth.schema import AuthSchema
async def db_getter() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库会话连接"""
async with session_connect() as session:
yield session
async with session.begin():
yield session
async def redis_getter(request: Request) -> Redis:
"""获取Redis连接"""
+17 -16
View File
@@ -105,22 +105,23 @@ class OperationLogRoute(APIRoute):
pass
else:
async with session_connect() as session:
auth = AuthSchema(db=session)
await OperationLogService.create_log_service(data=OperationLogCreateSchema(
type = log_type,
request_path = request.url.path,
request_method = request.method,
request_payload = payload,
request_ip = request_ip,
login_location=login_location,
request_os = user_agent.os.family,
request_browser = user_agent.browser.family,
response_code = response.status_code,
response_json = response_data.decode(),
process_time = process_time,
description = route.summary,
creator_id = current_user_id
), auth = auth)
async with session.begin():
auth = AuthSchema(db=session)
await OperationLogService.create_log_service(data=OperationLogCreateSchema(
type = log_type,
request_path = request.url.path,
request_method = request.method,
request_payload = payload,
request_ip = request_ip,
login_location=login_location,
request_os = user_agent.os.family,
request_browser = user_agent.browser.family,
response_code = response.status_code,
response_json = response_data.decode(),
process_time = process_time,
description = route.summary,
creator_id = current_user_id
), auth = auth)
return response
+19 -18
View File
@@ -31,7 +31,7 @@ from app.core.exceptions import (
ResponseValidationHandle,
ResponseValidationError
)
from app.core.database import session_connect, test_db_connection
from app.core.database import session_connect, init_create_table
from app.scripts.initialize import InitializeData
from app.api.v1.module_system.config.service import ConfigService
from app.api.v1.module_system.dict.service import DictDataService
@@ -46,30 +46,31 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
logger.info(settings.BANNER + '\n' + f'{settings.TITLE} 服务开始启动...')
try:
# 初始化数据库表
await init_create_table()
# 使用单个会话完成所有初始化操作
async with session_connect() as session:
# 测试数据库连接
await test_db_connection(session)
logger.info("数据库连接成功...")
async with session.begin():
# 初始化数据库
await InitializeData().init_db(db=session)
logger.info("初始化数据完成...")
# 初始化数据库数据
await InitializeData().init_db(db=session)
logger.info("初始化数据完成...")
# 初始化全局事件
await import_modules_async(modules=settings.EVENT_LIST, desc="全局事件", app=app, status=True)
# 初始化全局事件
await import_modules_async(modules=settings.EVENT_LIST, desc="全局事件", app=app, status=True)
# 初始化系统配置
await ConfigService().init_config_service(redis=app.state.redis, db=session)
logger.info("初始化系统配置完成...")
# 初始化系统配置
await ConfigService().init_config_service(redis=app.state.redis, db=session)
logger.info("初始化系统配置完成...")
# 初始化数据字典
await DictDataService().init_dict_service(redis=app.state.redis, db=session)
logger.info('初始化数据字典完成...')
# 初始化数据字典
await DictDataService().init_dict_service(redis=app.state.redis, db=session)
logger.info('初始化数据字典完成...')
# 初始化定时任务
await SchedulerUtil.init_system_scheduler(db=session)
logger.info('初始化定时任务完成...')
# 初始化定时任务
await SchedulerUtil.init_system_scheduler(db=session)
logger.info('初始化定时任务完成...')
logger.info(f'{settings.TITLE} 服务成功启动...')
except Exception as e:
+27 -118
View File
@@ -4,7 +4,7 @@ import uuid
import json
from pathlib import Path
from typing import Dict, List
from sqlalchemy import inspect, select, func, text
from sqlalchemy import inspect, select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_model import MappedBase
@@ -59,134 +59,46 @@ class InitializeData:
RoleDeptsModel,
RoleMenusModel,
]
# 需要更新序列的模型(排除关联表模型,因为它们没有id字段)
self.models_with_id = [
DeptModel,
MenuModel,
UserModel,
RoleModel,
PositionModel,
ConfigModel,
DictTypeModel,
NoticeModel,
OperationLogModel,
DictDataModel,
JobModel,
JobLogModel,
DemoModel,
ApplicationModel,
]
self.created_tables = set()
async def __get_existing_tables(self, db: AsyncSession) -> List[str]:
return await db.run_sync(
lambda sync_db: inspect(sync_db.get_bind()).get_table_names()
)
async def __init_model(self, db: AsyncSession) -> None:
"""初始化数据库表结构"""
try:
# 获取所有模型元数据
metadata = MappedBase.metadata
# 只创建不存在的表
for table in metadata.sorted_tables:
if table.name not in await self.__get_existing_tables(db):
await db.run_sync(lambda sync_db: table.create(sync_db.bind))
self.created_tables.add(table.name)
logger.info(f"已创建表: {table.name}")
await self.__init_data(db)
except Exception as e:
logger.error(f"初始化数据库结构失败: {str(e)}")
raise
async def __init_data(self, db: AsyncSession) -> None:
"""初始化基础数据"""
try:
inserted_data = False # 标记是否有数据插入
for model in self.prepare_init_models:
table_name = model.__tablename__
for model in self.prepare_init_models:
table_name = model.__tablename__
# 检查表中是否已经有数据
count_result = await db.execute(select(func.count()).select_from(model))
existing_count = count_result.scalar()
# 检查表中是否已经有数据
count_result = await db.execute(select(func.count()).select_from(model))
existing_count = count_result.scalar()
if existing_count > 0:
logger.warning(f"跳过 {table_name} 表数据初始化(表已存在 {existing_count} 条记录)")
continue
logger.info(f"检查表 {table_name} 数据: 已存在 {existing_count} 条记录")
data = await self.__get_data(table_name)
if not data:
logger.warning(f"跳过 {table_name} 表,无初始化数据")
continue
if existing_count > 0:
logger.warning(f"跳过 {table_name} 表数据初始化(表已存在 {existing_count} 条记录)")
continue
try:
# 表为空,直接插入全部数据
objs = [model(**item) for item in data]
db.add_all(objs)
await db.flush()
logger.info(f"已向 {table_name} 表写入 {len(objs)} 条记录")
data = await self.__get_data(table_name)
if not data:
logger.warning(f"跳过 {table_name} 表,无初始化数据")
continue
except Exception as e:
logger.error(f"初始化 {table_name} 表数据失败: {str(e)}")
raise
try:
# 表为空,直接插入全部数据
logger.info(f"准备向 {table_name} 表插入 {len(data)} 条记录")
objs = [model(**item) for item in data]
db.add_all(objs)
inserted_data = True
# 对于 PostgreSQL,更新序列值
if settings.DATABASE_TYPE == "postgresql" and model in self.models_with_id and len(objs) > 0:
await self.__update_postgresql_sequence_for_model(db, model, table_name)
logger.info(f"已向 {table_name} 表写入 {len(objs)} 条记录")
except Exception as e:
logger.error(f"初始化 {table_name} 表数据失败: {str(e)}")
raise
# 只有在有数据插入时才提交事务
if inserted_data:
await db.commit()
logger.info("数据初始化事务已提交")
else:
logger.info("没有新数据需要插入,跳过事务提交")
except Exception as e:
logger.error(f"初始化数据过程中出现错误: {str(e)}")
# 如果出现错误,回滚事务
await db.rollback()
raise
async def __update_postgresql_sequence_for_model(self, db: AsyncSession, model, table_name: str) -> None:
"""为特定模型更新 PostgreSQL 序列值"""
try:
# 检查模型是否有id属性
if not hasattr(model, 'id'):
return
# 获取表中最大的 ID 值
max_id_result = await db.execute(select(func.max(model.id)).select_from(model))
max_id = max_id_result.scalar()
if max_id is not None and max_id > 0:
# 更新序列值
sequence_name = f"{table_name}_id_seq"
await db.execute(text(f"SELECT setval('{sequence_name}', {max_id}, true)"))
logger.info(f"已更新 {table_name} 表的序列 {sequence_name} 值为 {max_id}")
except Exception as e:
logger.error(f"更新 {table_name} 表的 PostgreSQL 序列值失败: {str(e)}")
# 不抛出异常,因为序列更新失败不应该导致整个初始化失败
async def __get_data(self, table_name: str) -> List[Dict]:
async def __get_data(self, filename: str) -> List[Dict]:
"""读取初始化数据文件"""
json_path = Path.joinpath(settings.SCRIPT_DIR, f'{table_name}.json')
logger.info(f"尝试读取初始化数据文件: {json_path}")
json_path = Path.joinpath(settings.SCRIPT_DIR, f'{filename}.json')
if not json_path.exists():
logger.warning(f"初始化数据文件不存在: {json_path}")
return []
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.loads(f.read())
logger.info(f"成功读取 {table_name} 数据文件,包含 {len(data)} 条记录")
return data
return json.loads(f.read())
except json.JSONDecodeError as e:
logger.error(f"解析 {json_path} 失败: {str(e)}")
raise
@@ -198,8 +110,5 @@ class InitializeData:
"""
执行完整初始化流程
"""
logger.info("开始执行数据库初始化流程")
await self.__init_model(db)
# 刷新session以确保数据可见性
await db.flush()
logger.info("数据库初始化流程完成")
await self.__init_data(db)
+1 -1
View File
@@ -6,7 +6,7 @@ VITE_APP_TITLE=fastapiadmin
# 网络请求公用地址
VITE_API_BASE_URL=http://localhost:8001
VITE_API_BASE_URL=http://127.0.0.1:8001
# VITE_API_BASE_URL=https://service.fastapiadmin.com
# 代理前缀
+1 -1
View File
@@ -5,7 +5,7 @@ VITE_APP_ENV=production
VITE_APP_TITLE=fastapiadmin
# 网络请求公用地址
VITE_API_BASE_URL=http://localhost:8001
VITE_API_BASE_URL=https://service.fastapiadmin.com
# 代理前缀
VITE_APP_BASE_API=/api/v1