diff --git a/config.toml b/config.toml index 93f5c0d..75b5ceb 100644 --- a/config.toml +++ b/config.toml @@ -4,6 +4,8 @@ app.version = "chongming" app.description = "基于 FastAPI 的 chongming 打包脚手架" app.debug = false env = "development" +prefix = "/api/v1" +upload_path = "uploads" [database] type = "sqlite" @@ -12,6 +14,7 @@ generate_schemas = true # 生产环境使用迁移工具 [database.sqlite] echo = true +echo_pool = true pool_size = 20 max_overflow = 10 pool_timeout = 30 @@ -82,4 +85,4 @@ path = ["resources/app.mbank", "resources/plugins.mbank"] [production.file_system] type = "svfs" -path = "resources/application.vfs" +path = "static.svfs" diff --git a/public/config.toml b/public/config.toml index 7db073c..9d5dd44 100644 --- a/public/config.toml +++ b/public/config.toml @@ -4,6 +4,8 @@ app.version = "0.1.0" app.description = "基于 FastAPI 的 chongming 打包脚手架" app.debug = false env = "production" +prefix = "/api/v1" +upload_path = "uploads" [database] type = "sqlite" @@ -11,7 +13,8 @@ url = "sqlite+aiosqlite:///./database.db" generate_schemas = true # 生产环境使用迁移工具 [database.sqlite] -echo = true +echo = false +echo_pool = false pool_size = 20 max_overflow = 10 pool_timeout = 30 @@ -82,4 +85,4 @@ path = ["resources/app.mbank", "resources/plugins.mbank"] [production.file_system] type = "svfs" -path = "resources/application.vfs" +path = "static.svfs" diff --git a/public/main.py b/public/main.py index 2c31449..e7cb0db 100644 --- a/public/main.py +++ b/public/main.py @@ -27,6 +27,7 @@ def app_run(app_config: dict, default_config: dict): server_config = app_config.get("server", {}) host = server_config.get("host", "0.0.0.0") port = server_config.get("port", 8000) + timeout_keep_alive = server_config.get("timeout_keep_alive", 5) # 打包环境强制禁用重载 is_frozen = getattr(sys, "frozen", False) @@ -54,6 +55,7 @@ def app_run(app_config: dict, default_config: dict): workers=workers, log_level="debug" if app_config.get("debug", False) else "info", access_log=True, + timeout_keep_alive=timeout_keep_alive, ) diff --git a/pyproject.toml b/pyproject.toml index c9b21dc..2af445c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ description = "基于 modules-bank 的 fastapi 打包脚手架" readme = "README.md" requires-python = ">=3.8" dependencies = [ + "aiofiles>=24.1.0", "aiosqlite", "apscheduler", "bcrypt==4.0.1", @@ -17,6 +18,7 @@ dependencies = [ "module-bank", "passlib[bcrypt]", "pyjwt", + "python-multipart>=0.0.20", "sqlite-vfs", "sqlmodel[aiosqlite]", "tomli", diff --git a/src/chongming/app/__init__.py b/src/chongming/app/__init__.py index 78ca868..245867d 100644 --- a/src/chongming/app/__init__.py +++ b/src/chongming/app/__init__.py @@ -1,9 +1,10 @@ -import os +from pathlib import Path from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from sqlmodel import SQLModel, text from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.exc import OperationalError @@ -26,6 +27,7 @@ class TaskRequest(BaseModel): config = get_config() logger = get_logger("app") + try: env = config["default"]["env"] except KeyError: # pragma: no cover @@ -70,6 +72,7 @@ async def lifespan(app: FastAPI): if "already exists" in str(e): raise + # 启动时:初始化任务服务 task_service = get_task_service_instance(async_session_maker) app.state.task_service = task_service await task_service.start(init_tasks_callback) @@ -82,9 +85,8 @@ async def lifespan(app: FastAPI): get_cache().close() -cors = config[env].get("cors", {}) - # 创建 FastAPI 应用 +cors = config[env].get("cors", {}) app = FastAPI( title=config["default"]["app"]["name"], version=config["default"]["app"]["version"], @@ -94,9 +96,12 @@ app = FastAPI( openapi_url="/openapi.json" if config[env]["debug"] else None, lifespan=lifespan, ) + # 添加 CORS 中间件 app.add_middleware(CORSMiddleware, **cors) -app.include_router(api_router, prefix="/api/v1") + +# 添加路由 +app.include_router(api_router, prefix=config["default"]["prefix"]) # 根路由 @@ -107,7 +112,7 @@ async def root(): "message": f"欢迎使用 {config['default']['app']['name']}", "version": config["default"]["app"]["version"], "docs": "/docs" if config[env]["debug"] else None, - "health": "/api/v1/health", + "health": f"{config['default']['prefix']}/health", } @@ -124,10 +129,19 @@ async def root(): # ) # return {"status": "success", "job_id": job.id} +# 挂载静态文件 +config = get_config() +IMAGE_DIR = Path(f"./{config['default']['upload_path']}/images") +app.mount( + f"{config['default']['prefix']}/images", + StaticFiles(directory=f"./{config['default']['upload_path']}/images"), + name="images", +) -if os.path.exists("static.svfs"): +# 挂载前端静态文件 +if vfs_db_path := config[env].get("file_system", {}).get("path", None): logger.info("静态文件已存在,将使用静态文件服务") - static_files_handler = SVFSStaticFiles(directory="/") + static_files_handler = SVFSStaticFiles(directory="/", vfs_db_path=vfs_db_path) app.mount("/static", static_files_handler, name="static") diff --git a/src/chongming/app/api/__init__.py b/src/chongming/app/api/__init__.py index 7dd6c03..1d0c335 100644 --- a/src/chongming/app/api/__init__.py +++ b/src/chongming/app/api/__init__.py @@ -4,7 +4,7 @@ API 路由 from fastapi import APIRouter -from .routers import todo, auth +from .routers import todo, auth, upload # 创建主路由 api_router = APIRouter() @@ -12,6 +12,7 @@ api_router = APIRouter() # 注册子路由 api_router.include_router(todo.router, prefix="/todos", tags=["todo"]) api_router.include_router(auth.router, prefix="/auth", tags=["authentication"]) +api_router.include_router(upload.router, prefix="/upload", tags=["upload"]) # 导出路由 diff --git a/src/chongming/app/api/routers/auth.py b/src/chongming/app/api/routers/auth.py index 7ee88ca..95ef957 100644 --- a/src/chongming/app/api/routers/auth.py +++ b/src/chongming/app/api/routers/auth.py @@ -1,3 +1,5 @@ +from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession @@ -113,7 +115,7 @@ async def get_current_user_info( current_user: TokenData = Depends(get_current_user), ): """获取当前登录用户的信息""" - user = await UserService.get_user_by_id(int(current_user.user_id), session) + user = await UserService.get_user_by_id(UUID(current_user.user_id), session) if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") diff --git a/src/chongming/app/api/routers/upload.py b/src/chongming/app/api/routers/upload.py new file mode 100644 index 0000000..6a64a8b --- /dev/null +++ b/src/chongming/app/api/routers/upload.py @@ -0,0 +1,35 @@ +import os + +from fastapi import APIRouter, UploadFile, File, HTTPException, Depends +from pydantic import BaseModel + +from ..deps import get_current_user +from ...service.uploads import UploadService +from ...core.config import get_config + + +config = get_config() +router = APIRouter(dependencies=[Depends(get_current_user)]) +upload_image_service = UploadService( + config["default"]["upload_path"], 1024 * 1024, "images" +) + + +class UploadResponse(BaseModel): + url: str + filename: str + file_type: str + + +@router.post("/image", response_model=UploadResponse) +async def upload_image(file: UploadFile = File(...)): + + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="Invalid file type") + if file.filename is None: + raise HTTPException(status_code=400, detail="Invalid file name") + ext = os.path.splitext(file.filename)[1].lower() + if ext not in [".jpg", ".jpeg", ".png", ".gif", ".webp"]: + raise HTTPException(status_code=400, detail="Invalid file type") + url, filename = await upload_image_service.upload(file) + return UploadResponse(url=url, filename=filename, file_type=ext) diff --git a/src/chongming/app/core/config.py b/src/chongming/app/core/config.py index e6ef66c..09fed3d 100644 --- a/src/chongming/app/core/config.py +++ b/src/chongming/app/core/config.py @@ -23,6 +23,8 @@ class DefaultApp(TypedDict): class Default(TypedDict): app: DefaultApp env: Literal["production", "development"] + prefix: str + upload_path: str class SqliteConnectionArgs(TypedDict, total=False): @@ -86,7 +88,7 @@ class ModuleSystem(TypedDict, total=False): class FileSystem(TypedDict, total=False): type: str - path: List[str] + path: str class Env(TypedDict, total=False): diff --git a/src/chongming/app/core/constant.py b/src/chongming/app/core/constant.py new file mode 100644 index 0000000..fc40c05 --- /dev/null +++ b/src/chongming/app/core/constant.py @@ -0,0 +1,277 @@ +class PermissionConstant: + """ + 权限常量类 - 按业务模块分组管理所有系统权限 + + 该类定义了系统中各个业务模块的权限标识符,采用分层结构组织: + - 每个业务域作为一个内部类(如 Sales, Purchase, Inventory 等) + - 每个权限使用 "资源:操作" 格式定义 + - 支持 CRUD 操作及业务特定操作(如 approve, export 等) + + 使用示例: + >>> Permission.Sales.ORDER_CREATE # 获取销售订单创建权限 + 'sales_order:create' + >>> Permission.get_all() # 获取所有权限列表 + ['sales_order:create', 'sales_order:read', ...] + """ + + class Sales: + """ + 销售域权限常量 + + 包含销售相关的权限定义: + - 销售订单管理(ORDER_*) + - 销售出库管理(OUTBOUND_*) + - 客户管理(CUSTOMER_*) + + 权限格式: sales_order:create, customer:export 等 + """ + + ORDER_CREATE = "sales_order:create" # 创建销售订单 + ORDER_READ = "sales_order:read" # 读取销售订单 + ORDER_UPDATE = "sales_order:update" # 修改销售订单 + ORDER_DELETE = "sales_order:delete" # 删除销售订单 + + OUTBOUND_CREATE = "sales_outbound:create" # 创建销售出库 + OUTBOUND_READ = "sales_outbound:read" # 读取销售出库 + OUTBOUND_UPDATE = "sales_outbound:update" # 修改销售出库 + OUTBOUND_DELETE = "sales_outbound:delete" # 删除销售出库 + OUTBOUND_APPROVE = "sales_outbound:approve" # 审批销售出库 + + CUSTOMER_CREATE = "customer:create" # 创建客户 + CUSTOMER_READ = "customer:read" # 读取客户 + CUSTOMER_UPDATE = "customer:update" # 修改客户 + CUSTOMER_DELETE = "customer:delete" # 删除客户 + CUSTOMER_EXPORT = "customer:export" # 导出客户 + + class Purchase: + """ + 采购域权限常量 + + 包含采购相关的权限定义: + - 采购订单管理(ORDER_*) + - 采购入库管理(INBOUND_*),包含审批和取消操作 + - 供应商管理(SUPPLIER_*) + + 权限格式: purchase_order:create, supplier:update 等 + """ + + ORDER_CREATE = "purchase_order:create" # 创建采购订单 + ORDER_READ = "purchase_order:read" # 读取采购订单 + ORDER_UPDATE = "purchase_order:update" # 更新采购订单 + ORDER_DELETE = "purchase_order:delete" # 删除采购订单 + + INBOUND_CREATE = "purchase_inbound:create" # 创建采购入库 + INBOUND_READ = "purchase_inbound:read" # 读取采购入库 + INBOUND_UPDATE = "purchase_inbound:update" # 更新采购入库 + INBOUND_DELETE = "purchase_inbound:delete" # 删除采购入库 + INBOUND_APPROVE = "purchase_inbound:approve" # 审批采购入库 + INBOUND_CANCEL = "purchase_inbound:cancel" # 取消采购入库 + + SUPPLIER_CREATE = "supplier:create" # 创建供应商 + SUPPLIER_READ = "supplier:read" # 读取供应商 + SUPPLIER_UPDATE = "supplier:update" # 更新供应商 + SUPPLIER_DELETE = "supplier:delete" # 删除供应商 + + class Inventory: + """ + 库存域权限常量 + + 包含库存管理相关的权限定义: + - 库存管理(STOCK_*) + - 仓库管理(WAREHOUSE_*) + - 其他出入库单据(OTHER_INBOUND_*, OTHER_OUTBOUND_*) + - 调拨单管理(TRANSFER_*) + - 盘点单管理(STOCK_CHECK_*) + + 特点: 大部分单据支持审批流程(APPROVE 权限) + 权限格式: stock:read, transfer:approve 等 + """ + + STOCK_CREATE = "stock:create" # 创建库存 + STOCK_READ = "stock:read" # 读取库存 + STOCK_UPDATE = "stock:update" # 更新库存 + STOCK_DELETE = "stock:delete" # 删除库存 + + WAREHOUSE_CREATE = "warehouse:create" # 创建仓库 + WAREHOUSE_READ = "warehouse:read" # 读取仓库 + WAREHOUSE_UPDATE = "warehouse:update" # 更新仓库 + WAREHOUSE_DELETE = "warehouse:delete" # 删除仓库 + + # 库存单据 + + OTHER_INBOUND_CREATE = "other_inbound:create" # 创建其他入库 + OTHER_INBOUND_READ = "other_inbound:read" # 读取其他入库 + OTHER_INBOUND_UPDATE = "other_inbound:update" # 更新其他入库 + OTHER_INBOUND_DELETE = "other_inbound:delete" # 删除其他入库 + OTHER_INBOUND_APPROVE = "other_inbound:approve" # 审核其他入库 + + OTHER_OUTBOUND_CREATE = "other_outbound:create" # 创建其他出库 + OTHER_OUTBOUND_READ = "other_outbound:read" # 读取其他出库 + OTHER_OUTBOUND_UPDATE = "other_outbound:update" # 更新其他出库 + OTHER_OUTBOUND_DELETE = "other_outbound:delete" # 删除其他出库 + OTHER_OUTBOUND_APPROVE = "other_outbound:approve" # 审核其他出库 + + TRANSFER_CREATE = "transfer:create" # 创建调拨 + TRANSFER_READ = "transfer:read" # 读取调拨 + TRANSFER_UPDATE = "transfer:update" # 更新调拨 + TRANSFER_DELETE = "transfer:delete" # 删除调拨 + TRANSFER_APPROVE = "transfer:approve" # 审核调拨 + + STOCK_CHECK_CREATE = "stock_check:create" # 创建盘点 + STOCK_CHECK_READ = "stock_check:read" # 读取盘点 + STOCK_CHECK_UPDATE = "stock_check:update" # 更新盘点 + STOCK_CHECK_DELETE = "stock_check:delete" # 删除盘点 + STOCK_CHECK_APPROVE = "stock_check:approve" # 审核盘点 + + class Product: + """ + 产品主数据权限常量 + + 包含产品和基础数据管理的权限定义: + - 产品管理(PRODUCT_*),支持导入导出 + - 产品分类管理(CATEGORY_*) + - 计量单位管理(UNIT_*) + + 权限格式: product:import, product_category:read 等 + """ + + PRODUCT_CREATE = "product:create" # 创建产品 + PRODUCT_READ = "product:read" # 读取产品 + PRODUCT_UPDATE = "product:update" # 更新产品 + PRODUCT_DELETE = "product:delete" # 删除产品 + PRODUCT_IMPORT = "product:import" # 导入产品 + PRODUCT_EXPORT = "product:export" # 导出产品 + + CATEGORY_CREATE = "product_category:create" # 创建产品分类 + CATEGORY_READ = "product_category:read" # 读取产品分类 + CATEGORY_UPDATE = "product_category:update" # 更新产品分类 + CATEGORY_DELETE = "product_category:delete" # 删除产品分类 + + UNIT_CREATE = "unit:create" # 创建单位 + UNIT_READ = "unit:read" # 读取单位 + UNIT_UPDATE = "unit:update" # 更新单位 + UNIT_DELETE = "unit:delete" # 删除单位 + + class Finance: + """ + 财务域权限常量 + + 包含财务管理相关的权限定义: + - 应收应付查询(RECEIVABLE_READ, PAYABLE_READ) + - 付款管理(PAYMENT_*),包含审批流程 + - 发票管理(INVOICE_*) + + 注意: 财务域以只读权限为主,写操作需要审批 + 权限格式: payment:approve, invoice:create 等 + """ + + RECEIVABLE_READ = "receivable:read" # 读取应收应付 + PAYABLE_READ = "payable:read" # 读取应付 + PAYMENT_CREATE = "payment:create" # 创建付款 + PAYMENT_READ = "payment:read" # 读取付款 + PAYMENT_APPROVE = "payment:approve" # 审批付款 + INVOICE_CREATE = "invoice:create" # 创建发票 + INVOICE_READ = "invoice:read" # 读取发票 + + class Report: + """ + 报表与分析权限常量 + + 包含各类报表查看权限: + - 销售报表(SALES_VIEW) + - 采购报表(PURCHASE_VIEW) + - 库存报表(STOCK_VIEW) + - 财务报表(FINANCE_VIEW) + - 仪表盘(DASHBOARD_VIEW) + + 特点: 所有权限均为只读查看权限 + 权限格式: report:sales:view, dashboard:view 等 + """ + + SALES_VIEW = "report_sales:view" # 销售报表 + PURCHASE_VIEW = "report_purchase:view" # 采购报表 + STOCK_VIEW = "report_stock:view" # 库存报表 + FINANCE_VIEW = "report_finance:view" # 财务报表 + DASHBOARD_VIEW = "dashboard:view" # 仪表盘 + + class System: + """ + 系统管理权限常量 + + 包含系统级管理功能的权限定义: + - 人员管理(PERSONNEL_*) + - 角色管理(ROLE_*),包含角色分配权限 + - 系统配置(CONFIG_*) + - 审计日志(AUDIT_LOG_READ) + - 数据备份恢复(BACKUP_*) + + 注意: 这些是最高权限级别的操作,应谨慎分配 + 权限格式: role:assign, backup:restore 等 + """ + + PERSONNEL_CREATE = "personnel:create" # 创建人员 + PERSONNEL_READ = "personnel:read" # 读取人员 + PERSONNEL_UPDATE = "personnel:update" # 更新人员 + PERSONNEL_DELETE = "personnel:delete" # 删除人员 + + ROLE_CREATE = "role:create" # 创建角色 + ROLE_READ = "role:read" # 读取角色 + ROLE_UPDATE = "role:update" # 更新角色 + ROLE_DELETE = "role:delete" # 删除角色 + ASSIGN_PERMISSION = "permission:assign" # 分配权限 + ASSIGN_ROLE = "role:assign" # 分配角色 + + CONFIG_READ = "system_config:read" # 读取系统配置 + CONFIG_UPDATE = "system_config:update" # 更新系统配置 + AUDIT_LOG_READ = "audit_log:read" # 读取审计日志 + BACKUP_CREATE = "backup:create" # 创建备份 + BACKUP_RESTORE = "backup:restore" # 恢复备份 + + @classmethod + def get_all(cls): + """ + 获取系统中定义的所有权限字符串列表 + + 该方法通过反射机制自动收集所有内部类中定义的权限常量, + 用于数据库同步、权限初始化等场景。 + + 工作流程: + 1. 遍历 Permission 类的所有属性 + 2. 跳过私有属性和非类属性 + 3. 对每个内部类,遍历其所有字符串类型的权限常量 + 4. 过滤出符合 "resource:action" 格式的权限标识 + + Returns: + list[str]: 所有权限字符串的列表,例如: + [ + 'sales_order:create', + 'sales_order:read', + 'customer:export', + ... + ] + + 使用示例: + >>> all_perms = Permission.get_all() + >>> len(all_perms) # 获取权限总数 + 85 + >>> 'sales_order:create' in all_perms # 检查权限是否存在 + True + + Note: + - 该方法会自动排除以下划线开头的私有属性 + - 只收集包含冒号的字符串值,确保是有效的权限标识 + - 返回的列表顺序取决于 dir() 函数的返回顺序 + """ + perms = [] + for attr_name in dir(cls): + attr = getattr(cls, attr_name) + if attr_name.startswith("_") or not hasattr(attr, "__dict__"): + continue + # 遍历内部类的属性 + for sub_attr in dir(attr): + if sub_attr.startswith("_"): + continue + val = getattr(attr, sub_attr) + if isinstance(val, str) and ":" in val: + perms.append(val) + return perms diff --git a/src/chongming/app/core/logger.py b/src/chongming/app/core/logger.py index 9d30d91..5b026bf 100644 --- a/src/chongming/app/core/logger.py +++ b/src/chongming/app/core/logger.py @@ -21,6 +21,7 @@ class ExcludeWatchFilesFilter(logging.Filter): "apscheduler", "sqlalchemy", "sqlitedict", + "sqlalchemy.engine.Engine", ) # 重写过滤方法 diff --git a/src/chongming/app/core/mqtt.py b/src/chongming/app/core/mqtt.py new file mode 100644 index 0000000..e69de29 diff --git a/src/chongming/app/core/websocket.py b/src/chongming/app/core/websocket.py new file mode 100644 index 0000000..e69de29 diff --git a/src/chongming/app/model/__init__.py b/src/chongming/app/model/__init__.py index 10a4cb8..0fd63b2 100644 --- a/src/chongming/app/model/__init__.py +++ b/src/chongming/app/model/__init__.py @@ -1,5 +1,5 @@ from .user import User -from .role import Role +from .role import Role, RoleUpdate from .permission import Permission from .relationship_table import UserRole, RolePermission from .todo import Todo @@ -8,6 +8,7 @@ from .todo import Todo __all__ = [ "User", "Role", + "RoleUpdate", "Permission", "UserRole", "RolePermission", diff --git a/src/chongming/app/model/role.py b/src/chongming/app/model/role.py index c1c29ad..f11a3b3 100644 --- a/src/chongming/app/model/role.py +++ b/src/chongming/app/model/role.py @@ -1,6 +1,7 @@ from typing import Optional, List, TYPE_CHECKING from sqlmodel import SQLModel, Field, Relationship +from pydantic import BaseModel from .relationship_table import UserRole, RolePermission @@ -19,3 +20,8 @@ class Role(SQLModel, table=True): permissions: List["Permission"] = Relationship( back_populates="roles", link_model=RolePermission ) + + +class RoleUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None diff --git a/src/chongming/app/service/auth.py b/src/chongming/app/service/auth.py index 917949b..c695ffc 100644 --- a/src/chongming/app/service/auth.py +++ b/src/chongming/app/service/auth.py @@ -112,7 +112,7 @@ class AuthService: user_id = validation["user_id"] # 获取用户 - user = await UserService.get_user_by_id(int(user_id), session) + user = await UserService.get_user_by_id(user_id, session) if not user: return None diff --git a/src/chongming/app/service/permission.py b/src/chongming/app/service/permission.py new file mode 100644 index 0000000..531b232 --- /dev/null +++ b/src/chongming/app/service/permission.py @@ -0,0 +1,112 @@ +from typing import Optional +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from ..model import Permission, RolePermission, Role, User +from .user import UserService +from .role import RoleService + + +class PermissionService: + @classmethod + async def create_permission( + cls, + session: AsyncSession, + name: str, + resource: str, + action: str, + description: Optional[str] = None, + ) -> Permission: + permission = Permission( + name=name, resource=resource, action=action, description=description + ) + session.add(permission) + await session.commit() + await session.refresh(permission) + return permission + + @classmethod + async def get_permission_by_id( + cls, session: AsyncSession, permission_id: int + ) -> Optional[Permission]: + return await session.get(Permission, permission_id) + + @classmethod + async def get_permission_by_name( + cls, session: AsyncSession, name: str + ) -> Optional[Permission]: + result = await session.execute( + select(Permission).where(Permission.name == name) + ) + return result.scalar_one_or_none() + + @classmethod + async def assign_permission_to_role( + cls, session: AsyncSession, role_id: int, permission_id: int + ) -> RolePermission: + role_permission = RolePermission(role_id=role_id, permission_id=permission_id) + session.add(role_permission) + await session.commit() + await session.refresh(role_permission) + return role_permission + + @classmethod + async def assign_permission_to_user( + cls, + session: AsyncSession, + user_id: UUID, + permission_id: int, + ) -> User: + permission = await cls.get_permission_by_id(session, permission_id) + if not permission: + raise HTTPException(status_code=404, detail="Permission not found") + user = await UserService.get_user_by_id(user_id, session) + if user is None: + raise HTTPException(status_code=404, detail="用户不存在") + role = await RoleService.get_role_by_name(session, str(user_id)) + if role is None: + role = await RoleService.create_role( + session, str(user_id), is_only_user=True + ) + if role.id is None: + raise HTTPException(status_code=500, detail="角色ID无效") + if permission.id is None: + raise HTTPException(status_code=500, detail="权限ID无效") + await cls.assign_permission_to_role(session, role.id, permission.id) + user.roles.append(role) + await session.commit() + await session.refresh(user) + return user + + @classmethod + async def unbind_permission_from_role( + cls, session: AsyncSession, role_id: int, permission_id: int + ) -> Role: + role = await RoleService.get_role_by_id(session, role_id) + if role is None: + raise HTTPException(status_code=404, detail="Role not found") + role.permissions = [p for p in role.permissions if p.id != permission_id] + await session.commit() + await session.refresh(role) + return role + + @classmethod + async def unbind_permission_from_user( + cls, session: AsyncSession, user_id: UUID, permission_id: int + ): + user = await UserService.get_user_by_id(user_id, session) + if not user: + raise HTTPException(status_code=404, detail="User not found") + role = await RoleService.get_role_by_name(session, str(user.id)) + if role is None: + raise HTTPException(status_code=404, detail="Role not found") + role.permissions = [ + permission + for permission in role.permissions + if permission.id != permission_id + ] + await session.commit() + await session.refresh(role) diff --git a/src/chongming/app/service/role.py b/src/chongming/app/service/role.py new file mode 100644 index 0000000..52f200a --- /dev/null +++ b/src/chongming/app/service/role.py @@ -0,0 +1,97 @@ +from typing import Optional +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from ..model import Role, RoleUpdate, UserRole +from ..service.user import UserService + + +class RoleService: + @staticmethod + async def create_role( + session: AsyncSession, + name: str, + description: Optional[str] = None, + is_only_user: bool = False, + ) -> Role: + role = Role(name=name, description=description, is_only_user=is_only_user) + session.add(role) + await session.commit() + await session.refresh(role) + + return role + + @staticmethod + async def assign_role_to_user( + session: AsyncSession, + user_id: UUID, + role_id: int, + ): + user_role = UserRole(user_id=user_id, role_id=role_id) + session.add(user_role) + await session.commit() + await session.refresh(user_role) + + @classmethod + async def get_role_by_name(cls, session: AsyncSession, name: str) -> Optional[Role]: + result = await session.execute(select(Role).where(Role.name == name)) + return result.scalar_one_or_none() + + @classmethod + async def get_role_by_id( + cls, session: AsyncSession, role_id: int + ) -> Optional[Role]: + result = await session.get(Role, role_id) + return result + + @staticmethod + async def unbind_role_from_user( + session: AsyncSession, + user_id: UUID, + role_id: int, + ): + user = await UserService.get_user_by_id(user_id, session) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + user.roles = [role for role in user.roles if role.id != role_id] + await session.commit() + await session.refresh(user) + return user + + @staticmethod + async def delete_role( + role_id: int, + session: AsyncSession, + ) -> bool: + result = await session.execute( + select(UserRole).where(UserRole.role_id == role_id) + ) + if result.scalars().all(): + raise HTTPException(status_code=400, detail="Role is not empty") + role = await RoleService.get_role_by_id(session, role_id) + if role is None: + raise HTTPException(status_code=404, detail="Role not found") + await session.delete(role) + await session.commit() + return True + + @classmethod + async def update_role( + cls, + role_id: int, + role_update: RoleUpdate, + session: AsyncSession, + ) -> Optional[Role]: + role = await cls.get_role_by_id(session, role_id) + if role is None: + raise HTTPException(status_code=404, detail="Role not found") + role.name = role_update.name if role_update.name else role.name + role.description = ( + role_update.description if role_update.description else role.description + ) + await session.commit() + await session.refresh(role) + return role diff --git a/src/chongming/app/service/uploads.py b/src/chongming/app/service/uploads.py new file mode 100644 index 0000000..61c9009 --- /dev/null +++ b/src/chongming/app/service/uploads.py @@ -0,0 +1,43 @@ +import os +import time +import uuid +from pathlib import Path + +from fastapi.exceptions import HTTPException +from fastapi import UploadFile +import aiofiles + +from ..core.config import get_config + + +config = get_config() + + +class UploadService: + def __init__(self, upload_dir: str, chunk_size: int, file_type: str): + self.upload_dir = upload_dir + self.file_type = file_type + self.chunk_size = chunk_size + UPLOAD_DIR = Path(f"{self.upload_dir}/{self.file_type}") + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + + async def upload(self, file_data: UploadFile): + if file_data.filename is None: + raise HTTPException(status_code=400, detail="Invalid file name") + ext = os.path.splitext(file_data.filename)[1].lower() + new_filename = f"{uuid.uuid4().hex}{ext}" + file_path = os.path.join(self.upload_dir, self.file_type, new_filename) + try: + async with aiofiles.open(file_path, "wb") as out_file: + while chunk := await file_data.read(self.chunk_size): + await out_file.write(chunk) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + await file_data.close() + return ( + f"{config['default']['prefix']}/{self.file_type}/{new_filename}".replace( + "\\", "/" + ), + new_filename, + ) diff --git a/src/chongming/app/service/user.py b/src/chongming/app/service/user.py index 381835d..bff3658 100644 --- a/src/chongming/app/service/user.py +++ b/src/chongming/app/service/user.py @@ -1,4 +1,5 @@ from typing import Optional +from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -14,7 +15,7 @@ class UserService: return result.scalar_one_or_none() @staticmethod - async def get_user_by_id(user_id: int, session: AsyncSession) -> Optional[User]: + async def get_user_by_id(user_id: UUID, session: AsyncSession) -> Optional[User]: return await session.get(User, user_id) @staticmethod diff --git a/src/chongming/app/task/__init__.py b/src/chongming/app/task/__init__.py index 9667ba5..ca2ba25 100644 --- a/src/chongming/app/task/__init__.py +++ b/src/chongming/app/task/__init__.py @@ -1,13 +1,17 @@ -from typing import Callable, Optional - from ..core.scheduler import TaskService -from .execute_background import dev_init_admin +from ..core.config import get_config +from .dev_init_db import init_db + + +config = get_config() async def init_tasks_callback(task_service: TaskService): - await task_service.add_date_job( - dev_init_admin, - run_date=None, - args=[], - job_id="task_name", - ) + if config["default"]["env"] == "development": + + await task_service.add_date_job( + init_db, + run_date=None, + args=[], + job_id="task_name", + ) diff --git a/src/chongming/app/task/dev_init_db.py b/src/chongming/app/task/dev_init_db.py new file mode 100644 index 0000000..1ae5055 --- /dev/null +++ b/src/chongming/app/task/dev_init_db.py @@ -0,0 +1,39 @@ +from ..core.scheduler import logger +from ..core.scheduler import get_task_service_instance +from ..core.constant import PermissionConstant +from ..service.user import UserService +from ..service.permission import PermissionService + + +async def dev_init_admin(session): + admin_user = await UserService.get_user_by_email("admin", session) + if admin_user: # type: ignore + logger.info("管理员用户已存在") # type: ignore + return + await UserService.create_user(session, "admin", "admin", is_superuser=True) # type: ignore + + +async def init_permission(session): + for permission in PermissionConstant.get_all(): + parts = permission.split(":", 1) + resource = parts[0] + action = parts[1] if len(parts) > 1 else "" + + existing_permission = await PermissionService.get_permission_by_name( + session, permission + ) + if existing_permission: + logger.debug(f"权限已存在: {permission}") + continue + + await PermissionService.create_permission(session, permission, resource, action) + logger.info(f"创建权限: {permission}") + + +async def init_db(): + task_service = get_task_service_instance() + if task_service.async_session_maker is None: + return + async with task_service.async_session_maker() as session: + await dev_init_admin(session) + await init_permission(session) diff --git a/src/chongming/app/task/execute_background.py b/src/chongming/app/task/execute_background.py deleted file mode 100644 index 6d6ac15..0000000 --- a/src/chongming/app/task/execute_background.py +++ /dev/null @@ -1,15 +0,0 @@ -from ..core.scheduler import logger -from ..service.user import UserService -from ..core.scheduler import get_task_service_instance - - -async def dev_init_admin(): - task_service = get_task_service_instance() - if task_service.async_session_maker is None: - return - async with task_service.async_session_maker() as session: - admin_user = await UserService.get_user_by_email("admin", session) - if admin_user: # type: ignore - logger.info("管理员用户已存在") # type: ignore - return - await UserService.create_user(session, "admin", "admin", is_superuser=True) # type: ignore diff --git a/src/chongming/scripts/build.py b/src/chongming/scripts/build.py index 9899d51..2e0e95e 100644 --- a/src/chongming/scripts/build.py +++ b/src/chongming/scripts/build.py @@ -115,6 +115,8 @@ def run_pyinstaller(): "--hidden-import", "aiosqlite", "--hidden-import", + "aiofiles", + "--hidden-import", "apscheduler", "--hidden-import", "diskcache", @@ -127,6 +129,8 @@ def run_pyinstaller(): "--hidden-import", "sqlite_vfs", "--hidden-import", + "python_multipart", + "--hidden-import", "sqlite_vfs.core", "--hidden-import", "jwt", @@ -154,11 +158,12 @@ def run_pyinstaller(): async def init_database(): from sqlalchemy.ext.asyncio import create_async_engine - from ..app.core.config import get_config from sqlmodel import SQLModel, text from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - from ..app.service.user import UserService + + from ..app.core.config import get_config + from ..app.task.dev_init_db import init_permission, dev_init_admin database = get_config()["database"] database_type = database["type"] @@ -187,7 +192,9 @@ async def init_database(): raise # 添加管理员用户 async with async_session_maker() as session: - await UserService.create_user(session, "admin", "admin", is_superuser=True) # type: ignore + # await UserService.create_user(session, "admin", "admin", is_superuser=True) # type: ignore + await init_permission(session) + await dev_init_admin(session) async def main(): @@ -256,7 +263,6 @@ async def main(): shutil.rmtree(r"build/pyarmor_runtime_000000", ignore_errors=True) os.remove(r"build/main.py") os.remove(r"build/server.py") - # os.remove(r"build/config.toml") print("构建完成") diff --git a/uv.lock b/uv.lock index ce2999c..6c9a63f 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,31 @@ resolution-markers = [ "python_full_version < '3.9'", ] +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + [[package]] name = "aiosqlite" version = "0.20.0" @@ -337,6 +362,8 @@ name = "chongming" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiofiles", version = "24.1.0", source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" }, marker = "python_full_version < '3.9'" }, + { name = "aiofiles", version = "25.1.0", source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" }, marker = "python_full_version >= '3.9'" }, { name = "aiosqlite", version = "0.20.0", source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" }, marker = "python_full_version < '3.9'" }, { name = "aiosqlite", version = "0.22.1", source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" }, marker = "python_full_version >= '3.9'" }, { name = "apscheduler" }, @@ -349,6 +376,8 @@ dependencies = [ { name = "passlib", extra = ["bcrypt"] }, { name = "pyjwt", version = "2.9.0", source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" }, marker = "python_full_version < '3.9'" }, { name = "pyjwt", version = "2.11.0", source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" }, marker = "python_full_version >= '3.9'" }, + { name = "python-multipart", version = "0.0.20", source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" }, marker = "python_full_version < '3.10'" }, + { name = "python-multipart", version = "0.0.26", source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" }, marker = "python_full_version >= '3.10'" }, { name = "sqlite-vfs" }, { name = "sqlmodel", version = "0.0.29", source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" }, marker = "python_full_version < '3.9'" }, { name = "sqlmodel", version = "0.0.34", source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" }, marker = "python_full_version == '3.9.*'" }, @@ -370,6 +399,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiosqlite" }, { name = "apscheduler" }, { name = "bcrypt", specifier = "==4.0.1" }, @@ -378,6 +408,7 @@ requires-dist = [ { name = "module-bank" }, { name = "passlib", extras = ["bcrypt"] }, { name = "pyjwt" }, + { name = "python-multipart", specifier = ">=0.0.20" }, { name = "sqlite-vfs" }, { name = "sqlmodel", extras = ["aiosqlite"] }, { name = "tomli" }, @@ -1334,6 +1365,31 @@ wheels = [ { url = "https://mirrors.ustc.edu.cn/pypi/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.26" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/web/simple/" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3"