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:
freedakgmail
2026-07-07 09:04:47 +08:00
parent 8487f6eadf
commit 33b4c734aa
117 changed files with 22070 additions and 295 deletions
+65
View File
@@ -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()
+191
View File
@@ -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,
)
+65
View File
@@ -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)
+61
View File
@@ -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, [])
+76
View File
@@ -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
+108
View File
@@ -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