feat: initial commit - Phase 1 & 2 core features

This commit is contained in:
hiderfong
2026-04-22 17:07:33 +08:00
commit 1773bda06b
25005 changed files with 6252106 additions and 0 deletions
View File
+49
View File
@@ -0,0 +1,49 @@
from fastapi import Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from jose import JWTError
from app.core.database import get_db
from app.core.security import decode_token
from app.models.user import User
from app.services import user_service
def get_token_from_request(request: Request) -> str:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:]
# Fallback to query param for some special cases
token = request.query_params.get("token")
if token:
return token
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="缺少认证令牌",
headers={"WWW-Authenticate": "Bearer"},
)
def get_current_user(
request: Request,
db: Session = Depends(get_db),
) -> User:
token = get_token_from_request(request)
payload = decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证令牌",
headers={"WWW-Authenticate": "Bearer"},
)
user = user_service.get_user_by_id(db, int(payload.get("sub")))
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户已被禁用")
return user
def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
return current_user
+12
View File
@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.v1 import auth, user, datasource, metadata, classification, project, task
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["认证"])
api_router.include_router(user.router, prefix="/users", tags=["用户管理"])
api_router.include_router(datasource.router, prefix="/datasources", tags=["数据源管理"])
api_router.include_router(metadata.router, prefix="/metadata", tags=["元数据管理"])
api_router.include_router(classification.router, prefix="/classifications", tags=["分类分级标准"])
api_router.include_router(project.router, prefix="/projects", tags=["项目管理"])
api_router.include_router(task.router, prefix="/tasks", tags=["任务管理"])
+35
View File
@@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import decode_token, create_token_pair
from app.schemas.auth import LoginRequest, Token, TokenRefresh
from app.schemas.common import ResponseModel
from app.services.auth_service import login
router = APIRouter()
@router.post("/login", response_model=ResponseModel[Token])
def api_login(req: LoginRequest, db: Session = Depends(get_db)):
token_data = login(db, req.username, req.password)
return ResponseModel(data=Token(**token_data))
@router.post("/refresh", response_model=ResponseModel[Token])
def api_refresh(req: TokenRefresh):
payload = decode_token(req.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的刷新令牌",
)
user_id = int(payload.get("sub"))
username = payload.get("username")
access_token, refresh_token = create_token_pair(user_id, username)
return ResponseModel(data=Token(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=30 * 60,
))
+161
View File
@@ -0,0 +1,161 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.user import User
from app.schemas.classification import (
CategoryCreate, CategoryUpdate, CategoryOut, CategoryTree,
DataLevelOut, RecognitionRuleCreate, RecognitionRuleUpdate, RecognitionRuleOut,
TemplateOut,
)
from app.schemas.common import ResponseModel, ListResponse
from app.services import classification_service, classification_engine
from app.api.deps import get_current_user
router = APIRouter()
@router.get("/categories/tree", response_model=ResponseModel[list])
def get_category_tree(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tree = classification_service.build_category_tree(db)
return ResponseModel(data=tree)
@router.get("/categories", response_model=ResponseModel[list[CategoryOut]])
def list_categories(
parent_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
items = classification_service.list_categories(db, parent_id=parent_id)
return ResponseModel(data=[CategoryOut.model_validate(i) for i in items])
@router.post("/categories", response_model=ResponseModel[CategoryOut])
def create_category(
req: CategoryCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = classification_service.create_category(db, req)
return ResponseModel(data=CategoryOut.model_validate(item))
@router.put("/categories/{category_id}", response_model=ResponseModel[CategoryOut])
def update_category(
category_id: int,
req: CategoryUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
db_obj = classification_service.get_category(db, category_id)
if not db_obj:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
item = classification_service.update_category(db, db_obj, req)
return ResponseModel(data=CategoryOut.model_validate(item))
@router.delete("/categories/{category_id}")
def delete_category(
category_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
classification_service.delete_category(db, category_id)
return ResponseModel(message="删除成功")
@router.get("/levels", response_model=ResponseModel[list[DataLevelOut]])
def list_levels(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
items = classification_service.list_data_levels(db)
return ResponseModel(data=[DataLevelOut.model_validate(i) for i in items])
@router.get("/rules", response_model=ListResponse[RecognitionRuleOut])
def list_rules(
template_id: Optional[int] = Query(None),
keyword: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
items, total = classification_service.list_rules(db, template_id=template_id, keyword=keyword, page=page, page_size=page_size)
out = []
for i in items:
data = RecognitionRuleOut.model_validate(i)
data.category_name = i.category.name if i.category else None
data.level_name = i.level.name if i.level else None
data.level_color = i.level.color if i.level else None
out.append(data)
return ListResponse(data=out, total=total, page=page, page_size=page_size)
@router.post("/rules", response_model=ResponseModel[RecognitionRuleOut])
def create_rule(
req: RecognitionRuleCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = classification_service.create_rule(db, req)
data = RecognitionRuleOut.model_validate(item)
data.category_name = item.category.name if item.category else None
data.level_name = item.level.name if item.level else None
data.level_color = item.level.color if item.level else None
return ResponseModel(data=data)
@router.put("/rules/{rule_id}", response_model=ResponseModel[RecognitionRuleOut])
def update_rule(
rule_id: int,
req: RecognitionRuleUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
db_obj = classification_service.get_rule(db, rule_id)
if not db_obj:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="规则不存在")
item = classification_service.update_rule(db, db_obj, req)
data = RecognitionRuleOut.model_validate(item)
data.category_name = item.category.name if item.category else None
data.level_name = item.level.name if item.level else None
data.level_color = item.level.color if item.level else None
return ResponseModel(data=data)
@router.delete("/rules/{rule_id}")
def delete_rule(
rule_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
classification_service.delete_rule(db, rule_id)
return ResponseModel(message="删除成功")
@router.get("/templates", response_model=ResponseModel[list[TemplateOut]])
def list_templates(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
items = classification_service.list_templates(db)
return ResponseModel(data=[TemplateOut.model_validate(i) for i in items])
@router.post("/auto-classify/{project_id}")
def auto_classify(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = classification_engine.run_auto_classification(db, project_id)
return ResponseModel(data=result)
+81
View File
@@ -0,0 +1,81 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.user import User
from app.schemas.datasource import DataSourceCreate, DataSourceUpdate, DataSourceOut, DataSourceTest
from app.schemas.common import ResponseModel, ListResponse
from app.services import datasource_service
from app.api.deps import get_current_user
router = APIRouter()
@router.get("", response_model=ListResponse[DataSourceOut])
def list_datasources(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
keyword: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
items, total = datasource_service.list_datasources(db, keyword=keyword, page=page, page_size=page_size)
return ListResponse(data=[DataSourceOut.model_validate(i) for i in items], total=total, page=page, page_size=page_size)
@router.get("/{source_id}", response_model=ResponseModel[DataSourceOut])
def get_datasource(
source_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = datasource_service.get_datasource(db, source_id)
if not item:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="数据源不存在")
return ResponseModel(data=DataSourceOut.model_validate(item))
@router.post("", response_model=ResponseModel[DataSourceOut])
def create_datasource(
req: DataSourceCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = datasource_service.create_datasource(db, req, current_user.id)
return ResponseModel(data=DataSourceOut.model_validate(item))
@router.put("/{source_id}", response_model=ResponseModel[DataSourceOut])
def update_datasource(
source_id: int,
req: DataSourceUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
db_obj = datasource_service.get_datasource(db, source_id)
if not db_obj:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="数据源不存在")
item = datasource_service.update_datasource(db, db_obj, req)
return ResponseModel(data=DataSourceOut.model_validate(item))
@router.delete("/{source_id}")
def delete_datasource(
source_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
datasource_service.delete_datasource(db, source_id)
return ResponseModel(message="删除成功")
@router.post("/test-connection")
def test_connection(
req: DataSourceTest,
current_user: User = Depends(get_current_user),
):
result = datasource_service.test_connection(req)
return ResponseModel(data=result)
+66
View File
@@ -0,0 +1,66 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.user import User
from app.schemas.metadata import DatabaseOut, DataTableOut, DataColumnOut
from app.schemas.common import ResponseModel, ListResponse
from app.services import metadata_service
from app.api.deps import get_current_user
router = APIRouter()
@router.get("/tree")
def get_tree(
source_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tree = metadata_service.build_tree(db, source_id=source_id)
return ResponseModel(data=tree)
@router.get("/databases")
def list_databases(
source_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
items = metadata_service.list_databases(db, source_id=source_id)
return ResponseModel(data=[DatabaseOut.model_validate(i) for i in items])
@router.get("/tables")
def list_tables(
database_id: Optional[int] = Query(None),
keyword: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
items, total = metadata_service.list_tables(db, database_id=database_id, keyword=keyword)
return ListResponse(data=[DataTableOut.model_validate(i) for i in items], total=total, page=1, page_size=len(items))
@router.get("/columns")
def list_columns(
table_id: Optional[int] = Query(None),
keyword: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=500),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
items, total = metadata_service.list_columns(db, table_id=table_id, keyword=keyword, page=page, page_size=page_size)
return ListResponse(data=[DataColumnOut.model_validate(i) for i in items], total=total, page=page, page_size=page_size)
@router.post("/sync/{source_id}")
def sync_metadata(
source_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = metadata_service.sync_metadata(db, source_id, current_user.id)
return ResponseModel(data=result)
+100
View File
@@ -0,0 +1,100 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.user import User
from app.schemas.common import ResponseModel, ListResponse
from app.services import project_service
from app.api.deps import get_current_user
router = APIRouter()
@router.get("")
def list_projects(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
keyword: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
items, total = project_service.list_projects(db, keyword=keyword, page=page, page_size=page_size)
data = []
for p in items:
stats = project_service.get_project_stats(db, p.id)
data.append({
"id": p.id,
"name": p.name,
"template_id": p.template_id,
"status": p.status,
"planned_start": p.planned_start.isoformat() if p.planned_start else None,
"planned_end": p.planned_end.isoformat() if p.planned_end else None,
"created_at": p.created_at.isoformat() if p.created_at else None,
"stats": stats,
})
return ListResponse(data=data, total=total, page=page, page_size=page_size)
@router.post("")
def create_project(
name: str,
template_id: int,
target_source_ids: Optional[str] = None,
description: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = project_service.create_project(
db, name=name, template_id=template_id,
created_by=current_user.id,
target_source_ids=target_source_ids,
description=description,
)
return ResponseModel(data={"id": item.id, "name": item.name})
@router.get("/{project_id}")
def get_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
p = project_service.get_project(db, project_id)
if not p:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
stats = project_service.get_project_stats(db, p.id)
return ResponseModel(data={
"id": p.id,
"name": p.name,
"template_id": p.template_id,
"status": p.status,
"description": p.description,
"target_source_ids": p.target_source_ids,
"planned_start": p.planned_start.isoformat() if p.planned_start else None,
"planned_end": p.planned_end.isoformat() if p.planned_end else None,
"created_at": p.created_at.isoformat() if p.created_at else None,
"stats": stats,
})
@router.delete("/{project_id}")
def delete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
project_service.delete_project(db, project_id)
return ResponseModel(message="删除成功")
@router.post("/{project_id}/auto-classify")
def project_auto_classify(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.services.classification_engine import run_auto_classification
result = run_auto_classification(db, project_id)
return ResponseModel(data=result)
+80
View File
@@ -0,0 +1,80 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.user import User
from app.schemas.common import ResponseModel, ListResponse
from app.api.deps import get_current_user
router = APIRouter()
@router.get("/my-tasks")
def my_tasks(
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.models.project import ClassificationTask
query = db.query(ClassificationTask).filter(ClassificationTask.assignee_id == current_user.id)
if status:
query = query.filter(ClassificationTask.status == status)
items = query.order_by(ClassificationTask.created_at.desc()).all()
data = []
for t in items:
data.append({
"id": t.id,
"name": t.name,
"project_id": t.project_id,
"status": t.status,
"deadline": t.deadline.isoformat() if t.deadline else None,
"created_at": t.created_at.isoformat() if t.created_at else None,
})
return ResponseModel(data=data)
@router.get("/my-tasks/{task_id}/items")
def task_items(
task_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.models.project import ClassificationTask, ClassificationResult
from app.models.metadata import DataColumn, DataTable, Database as MetaDatabase, DataSource
from app.models.classification import Category, DataLevel
task = db.query(ClassificationTask).filter(ClassificationTask.id == task_id).first()
if not task:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在")
results = db.query(ClassificationResult).filter(
ClassificationResult.project_id == task.project_id,
).join(DataColumn).all()
data = []
for r in results:
col = r.column
table = col.table if col else None
database = table.database if table else None
source = database.source if database else None
data.append({
"result_id": r.id,
"column_id": col.id if col else None,
"column_name": col.name if col else None,
"data_type": col.data_type if col else None,
"comment": col.comment if col else None,
"table_name": table.name if table else None,
"database_name": database.name if database else None,
"source_name": source.name if source else None,
"category_id": r.category_id,
"category_name": r.category.name if r.category else None,
"level_id": r.level_id,
"level_name": r.level.name if r.level else None,
"level_color": r.level.color if r.level else None,
"source": r.source,
"confidence": r.confidence,
"status": r.status,
})
return ResponseModel(data=data)
+64
View File
@@ -0,0 +1,64 @@
from typing import Optional, List
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate, UserOut
from app.schemas.common import ResponseModel, ListResponse, PageParams
from app.services import user_service
from app.api.deps import get_current_user
router = APIRouter()
@router.get("/me", response_model=ResponseModel[UserOut])
def read_me(current_user: User = Depends(get_current_user)):
return ResponseModel(data=UserOut.model_validate(current_user))
@router.get("", response_model=ListResponse[UserOut])
def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
keyword: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
items, total = user_service.list_users(db, keyword=keyword, page=page, page_size=page_size)
return ListResponse(data=[UserOut.model_validate(u) for u in items], total=total, page=page, page_size=page_size)
@router.post("", response_model=ResponseModel[UserOut])
def create_user(
req: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
user = user_service.create_user(db, req)
return ResponseModel(data=UserOut.model_validate(user))
@router.put("/{user_id}", response_model=ResponseModel[UserOut])
def update_user(
user_id: int,
req: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
user = user_service.get_user_by_id(db, user_id)
if not user:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
user = user_service.update_user(db, user, req)
return ResponseModel(data=UserOut.model_validate(user))
@router.delete("/{user_id}")
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
user_service.delete_user(db, user_id)
return ResponseModel(message="删除成功")