feat: 完善前后端核心功能模块
后端: - 新增认证(auth)、任务(tasks)、映射(mappings)、对账(reconciliation)、异常(exceptions)、导出(exports) API - 新增核心模块: database, security, permissions, tenant, exceptions, error_handlers - 新增数据模型: user, company, reconciliation_task, field_mapping, uploaded_file 等 - 新增服务层: ai_recognizer, file_parser, file_storage, mapping, reconciliation 等 - 添加数据库迁移脚本 前端: - 新增登录页面和仪表盘页面 - 新增任务列表、任务详情、字段映射页面 - 新增异常处理页面和规则设置页面 - 新增 API 代理路由 /api/[...path] - 新增 UI 组件库 (button, card, dialog, input, table 等) - 新增 auth 组件 (ProtectedRoute, PermissionGate) - 新增 layout 组件 (Header, Sidebar) - 新增 mapping 组件 (FieldMappingTable, AISuggestionPanel) - 新增 API 客户端和 hooks (useAsync, useToast, usePermission 等) - 新增状态管理 (auth-store, company-store, ui-store) - 集成 Tailwind CSS 和 shadcn/ui 组件库 其他: - 添加 Alembic 数据库迁移配置 - 添加初始化示例数据脚本 - 更新项目文档
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# 创建异步数据库引擎
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
# 创建异步会话工厂
|
||||
async_session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
# 声明式基类
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
数据库会话依赖注入
|
||||
|
||||
Yields:
|
||||
AsyncSession: 数据库会话
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""
|
||||
初始化数据库(创建所有表)
|
||||
仅用于开发环境,生产环境使用 Alembic 迁移
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""
|
||||
关闭数据库连接
|
||||
"""
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,191 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.exceptions import (
|
||||
BusinessException,
|
||||
ExternalServiceException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
S2FException,
|
||||
UnauthorizedException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
def create_error_response(
|
||||
code: str,
|
||||
message: str,
|
||||
details: Any = None,
|
||||
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
创建统一错误响应
|
||||
|
||||
Args:
|
||||
code: 错误代码
|
||||
message: 错误消息
|
||||
details: 错误详情
|
||||
status_code: HTTP 状态码
|
||||
|
||||
Returns:
|
||||
JSON 响应
|
||||
"""
|
||||
error_data = {
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
}
|
||||
}
|
||||
|
||||
if details:
|
||||
error_data["error"]["details"] = details
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=error_data,
|
||||
)
|
||||
|
||||
|
||||
async def s2f_exception_handler(request: Request, exc: S2FException) -> JSONResponse:
|
||||
"""
|
||||
处理自定义业务异常
|
||||
|
||||
Args:
|
||||
request: 请求对象
|
||||
exc: 异常对象
|
||||
|
||||
Returns:
|
||||
JSON 响应
|
||||
"""
|
||||
logger.warning(
|
||||
"business_exception",
|
||||
code=exc.code,
|
||||
message=exc.message,
|
||||
details=exc.details,
|
||||
path=request.url.path,
|
||||
)
|
||||
|
||||
# 根据异常类型映射 HTTP 状态码
|
||||
status_code_map = {
|
||||
NotFoundException: status.HTTP_404_NOT_FOUND,
|
||||
UnauthorizedException: status.HTTP_401_UNAUTHORIZED,
|
||||
ForbiddenException: status.HTTP_403_FORBIDDEN,
|
||||
ValidationException: status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
BusinessException: status.HTTP_400_BAD_REQUEST,
|
||||
ExternalServiceException: status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
}
|
||||
|
||||
status_code = status_code_map.get(type(exc), status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
return create_error_response(
|
||||
code=exc.code,
|
||||
message=exc.message,
|
||||
details=exc.details,
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""
|
||||
处理请求验证异常
|
||||
|
||||
Args:
|
||||
request: 请求对象
|
||||
exc: 验证异常
|
||||
|
||||
Returns:
|
||||
JSON 响应
|
||||
"""
|
||||
logger.warning(
|
||||
"validation_error",
|
||||
errors=exc.errors(),
|
||||
body=exc.body,
|
||||
path=request.url.path,
|
||||
)
|
||||
|
||||
return create_error_response(
|
||||
code="VALIDATION_ERROR",
|
||||
message="请求参数验证失败",
|
||||
details={"errors": exc.errors()},
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
"""
|
||||
处理 HTTP 异常
|
||||
|
||||
Args:
|
||||
request: 请求对象
|
||||
exc: HTTP 异常
|
||||
|
||||
Returns:
|
||||
JSON 响应
|
||||
"""
|
||||
logger.warning(
|
||||
"http_exception",
|
||||
status_code=exc.status_code,
|
||||
detail=exc.detail,
|
||||
path=request.url.path,
|
||||
)
|
||||
|
||||
return create_error_response(
|
||||
code="HTTP_ERROR",
|
||||
message=str(exc.detail),
|
||||
status_code=exc.status_code,
|
||||
)
|
||||
|
||||
|
||||
async def integrity_error_handler(request: Request, exc: IntegrityError) -> JSONResponse:
|
||||
"""
|
||||
处理数据库完整性错误
|
||||
|
||||
Args:
|
||||
request: 请求对象
|
||||
exc: 完整性错误
|
||||
|
||||
Returns:
|
||||
JSON 响应
|
||||
"""
|
||||
logger.error(
|
||||
"database_integrity_error",
|
||||
error=str(exc),
|
||||
path=request.url.path,
|
||||
)
|
||||
|
||||
return create_error_response(
|
||||
code="DATABASE_ERROR",
|
||||
message="数据库操作失败,可能存在重复或约束冲突",
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
|
||||
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""
|
||||
处理未捕获的异常
|
||||
|
||||
Args:
|
||||
request: 请求对象
|
||||
exc: 异常对象
|
||||
|
||||
Returns:
|
||||
JSON 响应
|
||||
"""
|
||||
logger.error(
|
||||
"unhandled_exception",
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
path=request.url.path,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return create_error_response(
|
||||
code="INTERNAL_ERROR",
|
||||
message="服务器内部错误",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class S2FException(Exception):
|
||||
"""
|
||||
财务 AI 助手基础异常类
|
||||
|
||||
Attributes:
|
||||
message: 错误消息
|
||||
code: 错误代码
|
||||
details: 错误详情
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: str = "INTERNAL_ERROR",
|
||||
details: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class NotFoundException(S2FException):
|
||||
"""资源未找到异常"""
|
||||
|
||||
def __init__(self, message: str = "资源未找到", details: Optional[dict[str, Any]] = None) -> None:
|
||||
super().__init__(message=message, code="NOT_FOUND", details=details)
|
||||
|
||||
|
||||
class UnauthorizedException(S2FException):
|
||||
"""未授权异常"""
|
||||
|
||||
def __init__(self, message: str = "未授权访问", details: Optional[dict[str, Any]] = None) -> None:
|
||||
super().__init__(message=message, code="UNAUTHORIZED", details=details)
|
||||
|
||||
|
||||
class ForbiddenException(S2FException):
|
||||
"""禁止访问异常"""
|
||||
|
||||
def __init__(self, message: str = "禁止访问", details: Optional[dict[str, Any]] = None) -> None:
|
||||
super().__init__(message=message, code="FORBIDDEN", details=details)
|
||||
|
||||
|
||||
class ValidationException(S2FException):
|
||||
"""数据验证异常"""
|
||||
|
||||
def __init__(self, message: str = "数据验证失败", details: Optional[dict[str, Any]] = None) -> None:
|
||||
super().__init__(message=message, code="VALIDATION_ERROR", details=details)
|
||||
|
||||
|
||||
class BusinessException(S2FException):
|
||||
"""业务逻辑异常"""
|
||||
|
||||
def __init__(self, message: str, code: str = "BUSINESS_ERROR", details: Optional[dict[str, Any]] = None) -> None:
|
||||
super().__init__(message=message, code=code, details=details)
|
||||
|
||||
|
||||
class ExternalServiceException(S2FException):
|
||||
"""外部服务异常"""
|
||||
|
||||
def __init__(self, message: str = "外部服务调用失败", details: Optional[dict[str, Any]] = None) -> None:
|
||||
super().__init__(message=message, code="EXTERNAL_SERVICE_ERROR", details=details)
|
||||
@@ -0,0 +1,61 @@
|
||||
# 权限常量定义
|
||||
# 基于 PRD 第7章权限设计
|
||||
|
||||
# 任务管理权限
|
||||
PERM_TASK_CREATE = "task:create"
|
||||
PERM_TASK_VIEW = "task:view"
|
||||
PERM_TASK_UPDATE = "task:update"
|
||||
PERM_TASK_DELETE = "task:delete"
|
||||
PERM_TASK_EXPORT = "task:export"
|
||||
|
||||
# 用户管理权限
|
||||
PERM_USER_CREATE = "user:create"
|
||||
PERM_USER_VIEW = "user:view"
|
||||
PERM_USER_UPDATE = "user:update"
|
||||
PERM_USER_DELETE = "user:delete"
|
||||
|
||||
# 企业管理权限
|
||||
PERM_COMPANY_VIEW = "company:view"
|
||||
PERM_COMPANY_UPDATE = "company:update"
|
||||
|
||||
# 审计日志权限
|
||||
PERM_AUDIT_VIEW = "audit:view"
|
||||
|
||||
# 角色默认权限映射
|
||||
ROLE_PERMISSIONS = {
|
||||
"管理员": [
|
||||
PERM_TASK_CREATE, PERM_TASK_VIEW, PERM_TASK_UPDATE, PERM_TASK_DELETE, PERM_TASK_EXPORT,
|
||||
PERM_USER_CREATE, PERM_USER_VIEW, PERM_USER_UPDATE, PERM_USER_DELETE,
|
||||
PERM_COMPANY_VIEW, PERM_COMPANY_UPDATE,
|
||||
PERM_AUDIT_VIEW,
|
||||
],
|
||||
"财务主管": [
|
||||
PERM_TASK_CREATE, PERM_TASK_VIEW, PERM_TASK_UPDATE, PERM_TASK_DELETE, PERM_TASK_EXPORT,
|
||||
PERM_USER_CREATE, PERM_USER_VIEW, PERM_USER_UPDATE,
|
||||
PERM_COMPANY_VIEW,
|
||||
PERM_AUDIT_VIEW,
|
||||
],
|
||||
"会计": [
|
||||
PERM_TASK_CREATE, PERM_TASK_VIEW, PERM_TASK_UPDATE, PERM_TASK_EXPORT,
|
||||
PERM_USER_VIEW,
|
||||
],
|
||||
"出纳": [
|
||||
PERM_TASK_VIEW, PERM_TASK_EXPORT,
|
||||
],
|
||||
"人事": [
|
||||
PERM_TASK_VIEW,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_role_permissions(role: str) -> list[str]:
|
||||
"""
|
||||
获取角色的默认权限列表
|
||||
|
||||
Args:
|
||||
role: 角色名称
|
||||
|
||||
Returns:
|
||||
权限列表
|
||||
"""
|
||||
return ROLE_PERMISSIONS.get(role, [])
|
||||
@@ -0,0 +1,76 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
加密密码
|
||||
|
||||
Args:
|
||||
password: 明文密码
|
||||
|
||||
Returns:
|
||||
加密后的密码
|
||||
"""
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
验证密码
|
||||
|
||||
Args:
|
||||
plain_password: 明文密码
|
||||
hashed_password: 加密后的密码
|
||||
|
||||
Returns:
|
||||
密码是否正确
|
||||
"""
|
||||
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||
|
||||
|
||||
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
|
||||
"""
|
||||
创建访问令牌
|
||||
|
||||
Args:
|
||||
data: 要编码的数据
|
||||
expires_delta: 过期时间(可选)
|
||||
|
||||
Returns:
|
||||
JWT token
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.jwt_access_token_expire_minutes)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
解码访问令牌
|
||||
|
||||
Args:
|
||||
token: JWT token
|
||||
|
||||
Returns:
|
||||
解码后的数据,解码失败返回 None
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
@@ -0,0 +1,108 @@
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.company import Company
|
||||
from app.services.company import company_service
|
||||
|
||||
# 当前租户的上下文变量
|
||||
_current_company_id: ContextVar[Optional[int]] = ContextVar("current_company_id", default=None)
|
||||
|
||||
|
||||
def get_current_company_id(
|
||||
request: Request,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
获取当前租户 ID
|
||||
|
||||
从请求头 X-Company-ID 获取租户 ID
|
||||
|
||||
Args:
|
||||
request: FastAPI 请求对象
|
||||
|
||||
Returns:
|
||||
当前租户 ID,未设置则返回 None
|
||||
"""
|
||||
company_id_str = request.headers.get("X-Company-ID")
|
||||
|
||||
if not company_id_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(company_id_str)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def set_current_company_id(company_id: Optional[int]) -> None:
|
||||
"""
|
||||
设置当前租户 ID
|
||||
|
||||
Args:
|
||||
company_id: 租户 ID
|
||||
"""
|
||||
_current_company_id.set(company_id)
|
||||
|
||||
|
||||
async def get_current_company(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Optional[Company]:
|
||||
"""
|
||||
从请求头获取当前企业
|
||||
|
||||
支持两种方式:
|
||||
1. HTTP Header: X-Company-ID
|
||||
2. JWT Token 中的 company_id(后续实现认证后)
|
||||
|
||||
Args:
|
||||
request: FastAPI 请求对象
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
当前企业对象,不存在则返回 None
|
||||
"""
|
||||
# 从请求头获取 company_id
|
||||
company_id_str = request.headers.get("X-Company-ID")
|
||||
|
||||
if not company_id_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
company_id = int(company_id_str)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# 设置上下文变量
|
||||
set_current_company_id(company_id)
|
||||
|
||||
# 查询企业
|
||||
company = await company_service.get_company(db, company_id)
|
||||
return company
|
||||
|
||||
|
||||
def require_company(
|
||||
company: Optional[Company] = Depends(get_current_company),
|
||||
) -> Company:
|
||||
"""
|
||||
要求必须有企业上下文的依赖
|
||||
|
||||
Args:
|
||||
company: 当前企业
|
||||
|
||||
Returns:
|
||||
企业对象
|
||||
|
||||
Raises:
|
||||
HTTPException: 未提供企业 ID 或企业不存在
|
||||
"""
|
||||
if not company:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="未提供企业 ID 或企业不存在",
|
||||
)
|
||||
return company
|
||||
Reference in New Issue
Block a user