Commit b5ec3433 authored by uuo00_n's avatar uuo00_n

feat(鉴权): 统一鉴权方案并清理冗余字段

实现实体化模型鉴权依赖 require_binding,移除旧字段 head_teacher_id/teacher_id/user_id 及相关索引
补充接口文档与响应模型字段说明,完善仪表盘路由的角色与绑定校验
更新初始化脚本清理旧索引,添加文档说明迁移流程与验证要点
parent 5dbcb427
## 目标
- 彻底移除旧字段与索引,保证仅以实体化模型(bindings→persons→students/teachers)运转。
- 补充接口文档与示例,并统一鉴权口径(系统角色等级+实体身份)。
## 待执行工作
1) 统一鉴权依赖
-`app/api/deps.py` 增加 `require_binding(type)`,校验存在主绑定且实体类型匹配(student/teacher)。
- 学生端与班主任端路由分别挂载 `require_binding('student')``require_binding('teacher')`,中层/校级端保持角色等级校验。
2) 路由与服务清理
- `app/services/dashboard.py`
- 班主任端查询仅使用 `head_teacher_person_id`;移除对 `head_teacher_id` 的回退。
- 学生/中层/校级端均以“绑定→实体”解析路径,移除旧字段回退。
- `app/api/v1/students.py`:仅以绑定解析学生,不再读取旧 `user_id`
3) 数据与索引维护
- 运行维护脚本(或在 `init_db.py` 的维护段)删除旧索引:
- `classes.head_teacher_id_1``schedules.teacher_id_1``students.user_id_1`
- 确保集合不再写入或依赖旧字段:
- `classes.head_teacher_id``schedules.teacher_id``students.user_id`
4) 文档与示例
- 在文档中补充以下内容:
- 绑定接口用法与主绑定约束
- 人员/教师批量导入字段说明与示例
- 班级/课表实体化字段设置示例
- 仪表盘各角色返回示例(关键字段)
- 迁移流程与注意事项(导入顺序、索引清理)
5) 验证
- MCP 校验集合:确认不再存在旧字段与旧索引;`students.person_id``classes.head_teacher_person_id``schedules.teacher_person_id` 正常。
- 接口联调:
- 学生端:需主绑定且类型为 student;返回课表/出勤/操行
- 班主任端:以 `head_teacher_person_id` 定位所辖班;返回当前节次课程与地点、节次出勤、请假与指示
- 中层/校级端:统计与序列化正常,无 `ObjectId` 泄露
## 回滚策略
- 保留兼容分支的开关,在异常时临时恢复旧字段回退;完成 M4 验证后默认关闭。
如果你确认,我将按以上步骤执行:先实现依赖与代码清理,再完成索引维护与文档补充,最后进行 MCP 与接口联调验证。
\ No newline at end of file
# M4 统一鉴权与字段清理
## 鉴权口径
- 系统角色等级:1 学生、2 班主任、3 中层、4 校级、5 管理员
- 实体身份依赖:require_binding(student|teacher)
- 版别运行模式:require_edition_for_mode()
## 字段与索引
- classes:使用 head_teacher_person_id(索引);移除 head_teacher_id(索引)
- schedules:使用 teacher_person_id(索引);移除 teacher_id(索引)
- students:使用 person_id(稀疏唯一);移除 user_id(索引)
## 管理接口
- POST /api/v1/persons/bulk
- POST /api/v1/teachers/bulk
- PUT /api/v1/classes/{class_id}/head-teacher
- PUT /api/v1/schedules/assign-teacher
- POST /api/v1/bindings;GET /api/v1/bindings/me
## 仪表盘接口
- GET /api/v1/dashboard/student/today(role≥1 + binding=student)
- GET /api/v1/dashboard/homeroom/current(role≥2 + binding=teacher)
- GET /api/v1/dashboard/department/overview(role≥3)
- GET /api/v1/dashboard/campus/overview(role≥5)
## 迁移顺序
1. persons 导入→ 2. teachers 导入 → 3. classes 设置 head_teacher_person_id → 4. schedules 设置 teacher_person_id → 5. bindings 建主绑定 → 6. 清理旧索引与字段
## 验证
- MCP 检查集合字段与索引;联调各仪表盘接口返回示例。
## 目标
- 为所有接口补充清晰的中文 `summary/description`、请求/响应示例与错误码说明,使文档在 `/docs` 可直接理解与使用。
- 统一端口到 `http://127.0.0.1:8000/docs`(启动后端到 8000)以符合你的访问习惯。
## 全局文档配置
- `app/main.py`
- 在创建 `FastAPI()` 时设置 `description`(项目概览与角色说明)与 `openapi_tags`(认证、仪表盘、学生、绑定、人员、教师、班级、课表、管理员、对话)。
- 示例:为每个 Tag 增加中英文说明,便于分组导航。
## 路由级文档增强
- 统一在路由装饰器中加入:
- `summary`:一句话概述
- `description`:详细说明(包含角色要求、鉴权依赖、数据口径、注意事项)
- `responses`:标准错误(401/403/404)与成功示例(`example`
- 覆盖文件:
- 认证:`app/api/v1/auth.py`
- 仪表盘:`app/api/v1/dashboard.py`(学生/班主任/中层/校级)
- 学生:`app/api/v1/students.py`
- 绑定:`app/api/v1/bindings.py`
- 人员:`app/api/v1/persons.py`
- 教师:`app/api/v1/teachers.py`
- 班级:`app/api/v1/classes.py`
- 课表:`app/api/v1/schedules.py`
- 管理员:`app/api/v1/admin.py`
- 对话:`app/api/v1/conversation.py`
## 响应模型与字段说明
- 为关键接口新增/完善 `Pydantic` 响应模型:
- 使用 `Field(description=...)` 为每个字段提供中文说明
- 在模型 `model_config` 中设置 `title` 与示例(如 `json_schema_extra={'examples': [...]}`
- 典型模型:
- 登录响应 `Token` 增补 `person_id/person_type/bound_primary` 字段说明
- 仪表盘返回对象:学生端今日汇总、班主任端当前时段、中层/校级总览
- 绑定查询对象:`account_id/person_id/type/primary`
## 示例与错误码
-`responses` 中定义:
- `401` 未认证,`403` 权限不足或绑定不匹配,`404` 资源不存在
- `200` 示例:给出典型返回的完整示例(课表/出勤/操行/指示)
- 请求示例:
- 登录:`application/x-www-form-urlencoded``username/password`
- `POST /bindings``POST /persons/teachers/bulk` 的 JSON 示例体
## 端口统一与验证
- 将后端启动端口统一到 8000,验证 `http://127.0.0.1:8000/docs` 展示完整中文说明与示例。
- 通过实际请求核对各接口的示例与返回一致性。
## 不改业务逻辑
- 本次仅增强文档与描述,不改变接口的输入输出契约与鉴权逻辑。
确认后我将逐个路由补充 `summary/description/responses/example`,完善响应模型字段说明,并统一启动端口到 8000 进行文档验证。
\ No newline at end of file
......@@ -3,6 +3,8 @@ from fastapi.security import OAuth2PasswordBearer
from app.services.auth import get_current_user
from app.models.role import get_role_level
from app.core.config import settings
from bson import ObjectId
from app.db.mongodb import db
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
......@@ -71,4 +73,12 @@ def require_edition_for_mode():
)
return current_user
return _edition_checker
\ No newline at end of file
return _edition_checker
def require_binding(expected_type: str):
async def _binding_checker(current_user: dict = Depends(get_current_active_user)):
b = await db.db.bindings.find_one({"account_id": ObjectId(current_user["_id"]), "primary": True})
if not b or b.get("type") != expected_type:
raise HTTPException(status_code=403, detail="实体绑定不存在或类型不匹配")
return current_user
return _binding_checker
\ No newline at end of file
......@@ -9,7 +9,12 @@ from app.models.role import get_role_level
router = APIRouter()
@router.post("/register", response_model=UserResponse)
@router.post(
"/register",
response_model=UserResponse,
summary="用户注册",
description="创建基础账户(默认教育版、角色等级为1)。",
)
async def register(user_data: UserCreate):
"""注册新用户"""
# 检查用户名是否已存在
......@@ -53,7 +58,12 @@ async def register(user_data: UserCreate):
"edition": created_user.get("edition", "edu"),
}
@router.post("/login", response_model=Token)
@router.post(
"/login",
response_model=Token,
summary="用户登录",
description="使用表单登录(username/password),返回令牌与用户/绑定信息。",
)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
"""用户登录"""
user = await authenticate_user(form_data.username, form_data.password)
......
......@@ -10,21 +10,33 @@ class BindingPayload(BaseModel):
type: str
primary: bool = True
@router.post("")
@router.post(
"",
summary="创建主绑定",
description="为当前账号创建人物主绑定(student/teacher),同类型主绑定唯一。",
)
async def bind(payload: BindingPayload, current_user: dict = Depends(require_role(2))):
ok = await create_binding(str(current_user["_id"]), payload.person_id, payload.type, payload.primary)
if not ok:
raise HTTPException(status_code=400, detail="主绑定已存在或参数错误")
return {"success": True}
@router.delete("/{person_id}")
@router.delete(
"/{person_id}",
summary="删除绑定",
description="删除当前账号与指定人物的绑定。",
)
async def unbind(person_id: str, current_user: dict = Depends(require_role(2))):
ok = await delete_binding(str(current_user["_id"]), person_id)
if not ok:
raise HTTPException(status_code=404, detail="未找到绑定")
return {"success": True}
@router.get("/me")
@router.get(
"/me",
summary="查询当前账号的主绑定",
description="返回当前账号的主绑定信息(account_id/person_id/type/primary)。",
)
async def me(current_user: dict = Depends(get_current_active_user)):
b = await get_binding_by_account(str(current_user["_id"]))
if not b:
......
......@@ -9,14 +9,22 @@ router = APIRouter(dependencies=[Depends(require_edition_for_mode())])
class HeadTeacherPayload(BaseModel):
head_teacher_person_id: str
@router.put("/{class_id}/head-teacher")
@router.put(
"/{class_id}/head-teacher",
summary="设置班主任人物",
description="为班级设置 head_teacher_person_id(指向教师人物)。",
)
async def set_head_teacher(class_id: str, payload: HeadTeacherPayload, current_user: dict = Depends(require_role(3))):
res = await db.db.classes.update_one({"class_id": class_id}, {"$set": {"head_teacher_person_id": ObjectId(payload.head_teacher_person_id)}})
if res.matched_count == 0:
raise HTTPException(status_code=404, detail="班级不存在")
return {"success": True}
@router.get("")
@router.get(
"",
summary="列出班级",
description="按 class_id 排序列出班级信息(含 head_teacher_person_id)。",
)
async def list_classes(current_user: dict = Depends(require_role(2))):
res = []
cursor = db.db.classes.find({}).sort("class_id", 1)
......
from fastapi import APIRouter, Depends
from app.api.deps import require_edition_for_mode, require_role
from app.api.deps import require_edition_for_mode, require_role, require_binding
from app.services.dashboard import (
student_today_summary,
homeroom_current_summary,
......@@ -9,18 +9,34 @@ from app.services.dashboard import (
router = APIRouter(dependencies=[Depends(require_edition_for_mode())])
@router.get("/student/today")
async def student_today(current_user: dict = Depends(require_role(1))):
@router.get(
"/student/today",
summary="学生端:今日个人课表、出勤与操行",
description="需角色等级≥1且主绑定为学生;返回当天课表(共享节次按班级定位教室)、今日出勤记录与操行评语。"
)
async def student_today(current_user: dict = Depends(require_role(1)), _b: dict = Depends(require_binding("student"))):
return await student_today_summary(current_user)
@router.get("/homeroom/current")
async def homeroom_current(current_user: dict = Depends(require_role(2))):
@router.get(
"/homeroom/current",
summary="班主任端:当前节次课程与地点、出勤率、请假、指示",
description="需角色等级≥2且主绑定为教师;按 head_teacher_person_id 定位所辖班,统计当前节次的课程与地点、节次出勤率、今日请假与部门指示。"
)
async def homeroom_current(current_user: dict = Depends(require_role(2)), _b: dict = Depends(require_binding("teacher"))):
return await homeroom_current_summary(current_user)
@router.get("/department/overview")
@router.get(
"/department/overview",
summary="中层端:教师节次出勤率、学生出勤、异常班级、指示",
description="需角色等级≥3;按今日工作日统计每位教师的节次出勤率、全校学生出勤聚合、异常班级(缺勤+请假占比>0.3)与近期部门/校园指示。"
)
async def department(current_user: dict = Depends(require_role(3))):
return await department_overview(current_user)
@router.get("/campus/overview")
@router.get(
"/campus/overview",
summary="校级端:校园整体总览",
description="需角色等级≥5;返回学生总数、今日出勤、请假与指示数量等宏观数据。"
)
async def campus(current_user: dict = Depends(require_role(5))):
return await campus_overview(current_user)
\ No newline at end of file
......@@ -12,7 +12,11 @@ class PersonCreate(BaseModel):
name: str
type: str = Field(pattern="^(student|teacher|staff)$")
@router.post("/bulk")
@router.post(
"/bulk",
summary="批量导入人物档案",
description="导入学生/教师/职员的基础人物信息(person_id/name/type)。",
)
async def bulk_create(persons: List[PersonCreate], current_user: dict = Depends(require_role(3))):
docs = [{"person_id": p.person_id, "name": p.name, "type": p.type} for p in persons]
if not docs:
......@@ -20,7 +24,11 @@ async def bulk_create(persons: List[PersonCreate], current_user: dict = Depends(
await db.db.persons.insert_many(docs)
return {"inserted": len(docs)}
@router.get("")
@router.get(
"",
summary="列出人物档案",
description="按 person_id 排序列出所有人物档案。",
)
async def list_persons(current_user: dict = Depends(require_role(3))):
res = []
cursor = db.db.persons.find({}).sort("person_id", 1)
......
......@@ -10,14 +10,22 @@ class AssignTeacherPayload(BaseModel):
lesson_id: str
teacher_person_id: str
@router.put("/assign-teacher")
@router.put(
"/assign-teacher",
summary="为节次设置任课教师人物",
description="将指定 lesson_id 的节次设置为 teacher_person_id(教师人物)。",
)
async def assign_teacher(payload: AssignTeacherPayload, current_user: dict = Depends(require_role(3))):
res = await db.db.schedules.update_one({"lesson_id": payload.lesson_id}, {"$set": {"teacher_person_id": ObjectId(payload.teacher_person_id)}})
if res.matched_count == 0:
raise HTTPException(status_code=404, detail="节次不存在")
return {"success": True}
@router.get("")
@router.get(
"",
summary="列出课表节次",
description="按工作日与节次排序列出共享节次(含各班 location 与 teacher_person_id)。",
)
async def list_schedules(current_user: dict = Depends(require_role(2))):
res = []
cursor = db.db.schedules.find({}).sort([("weekday", 1), ("period", 1)])
......
......@@ -14,7 +14,11 @@ class TeacherCreate(BaseModel):
roles: List[str]
account_id: Optional[str] = None
@router.post("/bulk")
@router.post(
"/bulk",
summary="批量导入教师实体",
description="导入教师实体(person_id/teacher_id/department/roles),可选绑定 account_id。",
)
async def bulk_create(teachers: List[TeacherCreate], current_user: dict = Depends(require_role(3))):
docs = []
for t in teachers:
......@@ -32,7 +36,11 @@ async def bulk_create(teachers: List[TeacherCreate], current_user: dict = Depend
await db.db.teachers.insert_many(docs)
return {"inserted": len(docs)}
@router.get("")
@router.get(
"",
summary="列出教师实体",
description="按 teacher_id 排序列出所有教师实体(包含 person_id/account_id)。",
)
async def list_teachers(current_user: dict = Depends(require_role(3))):
res = []
cursor = db.db.teachers.find({}).sort("teacher_id", 1)
......
......@@ -7,6 +7,24 @@ from app.utils.sensitive_word_filter import sensitive_word_filter
app = FastAPI(
title=settings.APP_NAME,
description=(
"LLM 过滤系统后端接口\n\n"
"角色等级:1 学生、2 班主任、3 中层、4 校级、5 管理员。\n"
"实体绑定:通过 /bindings 建立账号与人物(students/teachers)的主绑定。\n"
"运行模式:APP_MODE=edu/biz,路由统一受版别依赖控制。"
),
openapi_tags=[
{"name": "认证", "description": "注册、登录,返回令牌与用户基础信息(含绑定信息)"},
{"name": "仪表盘", "description": "学生/班主任/中层/校级看板数据接口"},
{"name": "学生", "description": "学生实体相关接口(按绑定解析)"},
{"name": "绑定", "description": "账号与人物绑定管理(主绑定唯一)"},
{"name": "人员", "description": "人物档案管理(学生/教师/职员等)"},
{"name": "教师", "description": "教师实体导入与查询(含班主任/干部角色)"},
{"name": "班级", "description": "班级管理(设置班主任为 head_teacher_person_id)"},
{"name": "课表", "description": "课表管理(为节次设置 teacher_person_id;共享节次)"},
{"name": "管理员", "description": "管理员功能(敏感词、分类等)"},
{"name": "对话", "description": "对话与敏感词审计接口"},
],
openapi_url=f"{settings.API_V1_STR}/openapi.json"
)
......
from typing import Optional, Literal
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
username: str
......@@ -22,14 +22,19 @@ class UserResponse(BaseModel):
edition: Literal["edu", "biz"]
class Token(BaseModel):
access_token: str
token_type: str
access_token: str = Field(description="访问令牌(JWT)")
token_type: str = Field(description="令牌类型,固定为 bearer")
# 扩展:在登录响应中返回关键用户属性,减少额外查询(也可单独提供 /me 接口)
# 使用 Literal 限定角色取值范围,保持与 UserResponse 一致
role: Optional[Literal["user", "manager", "leader", "master", "administrator", "admin"]] = None
role_level: Optional[int] = None
role: Optional[Literal["user", "manager", "leader", "master", "administrator", "admin"]] = Field(default=None, description="系统角色")
role_level: Optional[int] = Field(default=None, description="角色等级(1~5)")
# 使用 Literal 限定版别取值范围
edition: Optional[Literal["edu", "biz"]] = None
edition: Optional[Literal["edu", "biz"]] = Field(default=None, description="版别(教育/企业)")
# 绑定信息(实体化)
person_id: Optional[str] = Field(default=None, description="主绑定的人物ID(persons._id)")
person_type: Optional[Literal["student", "teacher", "staff"]] = Field(default=None, description="主绑定人物类型")
bound_primary: Optional[bool] = Field(default=None, description="是否存在主绑定")
class TokenData(BaseModel):
user_id: Optional[str] = None
......
......@@ -104,7 +104,7 @@ async def homeroom_current_summary(current_user: Dict[str, Any]) -> Dict[str, An
current_lesson_id = f"W{weekday}-P{period}"
uid = ObjectId(current_user["_id"])
classes = []
cursor = db.db.classes.find({"$or": [{"head_teacher_person_id": uid}, {"head_teacher_id": uid}]})
cursor = db.db.classes.find({"head_teacher_person_id": uid})
async for c in cursor:
classes.append(c)
class_ids = [c.get("class_id") for c in classes]
......
......@@ -624,5 +624,18 @@ async def seed_identity_data(db, mode: str):
await db.schedules.update_many({}, {"$unset": {"teacher_id": ""}})
await db.students.update_many({}, {"$unset": {"user_id": ""}})
try:
await db.classes.drop_index("head_teacher_id_1")
except Exception:
pass
try:
await db.schedules.drop_index("teacher_id_1")
except Exception:
pass
try:
await db.students.drop_index("user_id_1")
except Exception:
pass
if __name__ == "__main__":
asyncio.run(init_db())
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment