Commit 05ab73d7 authored by uuo00_n's avatar uuo00_n

feat(学生绑定): 实现用户与学生绑定功能及接口

- 新增学生绑定服务与API路由
- 修改dashboard服务去除随机学生fallback逻辑
- 在students集合添加user_id稀疏唯一索引
- 更新init_db.py添加索引初始化
- 完善错误处理与返回数据序列化
parent 65250db5
## 现状与差距
- 已有接口:`GET /api/v1/dashboard/homeroom/current`(角色≥2,受版别限制)。功能包含:
- 当前时段课程与地点(按登录时间映射节次)
- 所辖班级今日整体出勤率(present/total)
- 今日请假列表(time-window 过滤)
- 部门级近期指示(倒序取前 N 条)
- 待完善:
- 按“当前节次”统计出勤(而不是全天)
- 请假与指示的过滤更精确(按系部/班级目标)
- 响应统一可序列化(去除 `ObjectId` 等)
## 接口与权限
- 路由:`GET /api/v1/dashboard/homeroom/current`
- 依赖:`Depends(require_edition_for_mode())` + `Depends(require_role(2))`
- 当前用户定位班级:`classes.head_teacher_id == current_user._id`
## 数据口径(最终返回)
- `current_lessons`
- 每个所辖班在当前节次的课程名、地点(从共享节次 `schedules.classes` 中匹配 `class_id`
- `attendance_rates`
- 每班“当前节次”的出勤数据:`present/total/rate`(从 `attendance``lesson_id=W{weekday}-P{period}` 精确统计)
- `leaves`
- 今日处于有效区间的请假记录(`from_date<=today<=to_date`),返回 `student_id/class_id/reason/status`
- `directives`
- 部门级指示(可选:按目标包含所辖班级或专业筛选),倒序最近 N 条
## 技术实现
- 服务层(`app/services/dashboard.py`
- 统一序列化字典,不返回不可序列化类型
- 当前节次计算:使用登录时间(小时→节次)
- 出勤统计粒度:`attendance.count_documents({class_id, date=today, lesson_id=current_lesson_id, status='出勤'})`
- 索引与性能
- 已有索引:`students.user_id``classes.head_teacher_id``schedules(classes.class_id, weekday, period)``attendance(lesson_id, student_id)``leaves(class_id, from_date)`
- 查询按索引字段过滤,保持 O(logN) 级命中
## 验证方案
- MCP 校验:
-`head_teacher_id` 列出所辖班;按 `weekday+period` 查当前节次课表;按 `lesson_id+class_id` 统计出勤
- 接口联调:
- 使用 `manager_edu` 登录,访问 `/dashboard/homeroom/current` 检查四块数据完整性
## 交付步骤
1. 优化 `homeroom_current_summary` 的出勤统计粒度到“当前节次”
2. 指示列表按部门或目标过滤,统一序列化输出
3. 加入最小限度的错误兜底(无所辖班时返回空集合)
4. 通过 MCP 与本地接口联调验证
## 安全与回滚
- 仅读取查询,无数据破坏;若需回滚,保留原全天出勤实现分支
确认后我将立即完善服务实现并复核接口返回,随后重启后端(端口可改回 8000 或继续 8001)进行联调验证。
\ No newline at end of file
## 问题与目标
- 现状:仅示例用户 `user` 绑定了一个学生,其余学生未与用户账户关联,导致学生端接口需要 fallback,功能割裂。
- 目标:建立稳定的“用户↔学生”绑定关系与接口,使学生端数据根据登录用户精确返回;同时保证班主任、中层、校级端不受影响。
## 数据库与索引
1. `students` 集合新增索引:
- `user_id` 唯一(稀疏),确保一个用户仅绑定一个学生;便于按用户查学生
- 维持现有:`student_id` 唯一、`class_id` 索引
2. 可选(若需要反查更快):
- `users` 集合新增字段:`student_id`(非必需);无需索引,主要用于展示与快捷反查
## 服务与接口
1. 新增服务 `app/services/student_binding.py`
- `bind_user_to_student(user_id, student_id)`:校验重复绑定,写入 `students.user_id`,可同步回写 `users.student_id`
- `unbind_user_from_student(student_id)`:移除绑定;可同步清理 `users.student_id`
- `get_student_by_user(user_id)`:按 `students.user_id` 查询返回学生
2. 新增路由 `app/api/v1/students.py`
- `POST /students/{student_id}/bind`(管理员或班主任,≥2级):请求体携带 `user_id`
- `DELETE /students/{student_id}/bind`(管理员或班主任,≥2级)
- `GET /students/me`(学生本人,≥1级):返回当前登录用户绑定的学生信息
- 路由统一挂载 `Depends(require_edition_for_mode())`,角色控制用 `require_role(min_level)`
## 学生端接口对齐
- 更新 `app/services/dashboard.py`
- 去掉“未绑定时随机学生”的 fallback,若当前用户未绑定学生则返回 404(或明确错误)
- 查询逻辑改为严格基于 `students.user_id``student_id/class_id`,再取当天课表/出勤/操行
## 初始化数据与迁移
-`init_db.py` 中:
- 保留已绑定示例 `user ↔ SW22-1-001`
- 新增批量绑定工具函数(可选):用于演示环境将若干学生与新创建的用户批量绑定(仅本地开发)
- 正式环境:由管理员通过新接口进行绑定
## 验证
- 使用 MCP:
- 检查 `students` 索引与绑定结果(`count``query`
- 按用户查询学生 `get_student_by_user` 的返回
- 通过接口:
- 学生登录访问 `/dashboard/student/today` 能准确返回本人数据;未绑定返回明确错误
- 班主任、中层、校级端接口不受影响
## 变更范围
- 新增文件:`app/services/student_binding.py``app/api/v1/students.py`
- 修改文件:`app/services/dashboard.py`(去除 fallback)、`app/api/v1/router.py`(注册 students 路由)、`init_db.py`(索引与演示绑定可选)
## 回滚与安全
- 仅在 `students.user_id` 写入引用,不影响学生主键;解绑接口可恢复
- 索引为稀疏唯一,避免未绑定数据被约束;写入时做并发校验,防止竞态
请确认以上方案,我将按此落地实现并完成 MCP 与接口验证。
\ No newline at end of file
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, conversation, admin, dashboard from app.api.v1 import auth, conversation, admin, dashboard, students
api_router = APIRouter() api_router = APIRouter()
...@@ -7,4 +7,5 @@ api_router = APIRouter() ...@@ -7,4 +7,5 @@ api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["认证"]) api_router.include_router(auth.router, prefix="/auth", tags=["认证"])
api_router.include_router(conversation.router, prefix="/conversations", tags=["对话"]) api_router.include_router(conversation.router, prefix="/conversations", tags=["对话"])
api_router.include_router(admin.router, prefix="/admin", tags=["管理员"]) api_router.include_router(admin.router, prefix="/admin", tags=["管理员"])
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["仪表盘"]) api_router.include_router(dashboard.router, prefix="/dashboard", tags=["仪表盘"])
\ No newline at end of file api_router.include_router(students.router, prefix="/students", tags=["学生"])
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.api.deps import require_edition_for_mode, require_role
from app.services.student_binding import bind_user_to_student, unbind_user_from_student, get_student_by_user
router = APIRouter(dependencies=[Depends(require_edition_for_mode())])
class BindPayload(BaseModel):
user_id: str
@router.post("/{student_id}/bind")
async def bind(student_id: str, payload: BindPayload, current_user: dict = Depends(require_role(2))):
ok = await bind_user_to_student(payload.user_id, student_id)
if not ok:
raise HTTPException(status_code=400, detail="用户已绑定其他学生或学生不存在")
return {"success": True}
@router.delete("/{student_id}/bind")
async def unbind(student_id: str, current_user: dict = Depends(require_role(2))):
ok = await unbind_user_from_student(student_id)
if not ok:
raise HTTPException(status_code=404, detail="学生不存在或未绑定")
return {"success": True}
@router.get("/me")
async def me(current_user: dict = Depends(require_role(1))):
s = await get_student_by_user(str(current_user["_id"]))
if not s:
raise HTTPException(status_code=404, detail="当前用户未绑定学生")
s["_id"] = str(s["_id"]) # 简化返回
return s
\ No newline at end of file
from datetime import datetime from datetime import datetime
from typing import Dict, List, Any from typing import Dict, List, Any
from bson import ObjectId from bson import ObjectId
from fastapi import HTTPException
from app.db.mongodb import db from app.db.mongodb import db
async def _today_iso() -> str: async def _today_iso() -> str:
...@@ -23,12 +24,12 @@ async def _get_student_by_user(user_id: ObjectId) -> Dict[str, Any]: ...@@ -23,12 +24,12 @@ async def _get_student_by_user(user_id: ObjectId) -> Dict[str, Any]:
s = await db.db.students.find_one({"user_id": user_id}) s = await db.db.students.find_one({"user_id": user_id})
if s: if s:
return s return s
return await db.db.students.find_one({}) raise HTTPException(status_code=404, detail="当前用户未绑定学生")
async def student_today_summary(current_user: Dict[str, Any]) -> Dict[str, Any]: async def student_today_summary(current_user: Dict[str, Any]) -> Dict[str, Any]:
today = await _today_iso() today = await _today_iso()
weekday = await _weekday() weekday = await _weekday()
student = await _get_student_by_user(ObjectId(current_user["_id"])) student = await _get_student_by_user(current_user["_id"])
class_id = student.get("class_id") if student else None class_id = student.get("class_id") if student else None
schedules: List[Dict[str, Any]] = [] schedules: List[Dict[str, Any]] = []
...@@ -56,7 +57,16 @@ async def student_today_summary(current_user: Dict[str, Any]) -> Dict[str, Any]: ...@@ -56,7 +57,16 @@ async def student_today_summary(current_user: Dict[str, Any]) -> Dict[str, Any]:
"status": a.get("status"), "status": a.get("status"),
}) })
conduct = await db.db.conduct.find_one({"student_id": student.get("student_id") if student else None, "date": today}) conduct_doc = await db.db.conduct.find_one({"student_id": student.get("student_id") if student else None, "date": today})
conduct = {}
if conduct_doc:
conduct = {
"date": conduct_doc.get("date"),
"metrics": conduct_doc.get("metrics"),
"teacher_comment": conduct_doc.get("teacher_comment"),
"head_teacher_comment": conduct_doc.get("head_teacher_comment"),
"score": conduct_doc.get("score"),
}
return { return {
"student": { "student": {
...@@ -66,7 +76,7 @@ async def student_today_summary(current_user: Dict[str, Any]) -> Dict[str, Any]: ...@@ -66,7 +76,7 @@ async def student_today_summary(current_user: Dict[str, Any]) -> Dict[str, Any]:
}, },
"today_schedule": schedules, "today_schedule": schedules,
"today_attendance": attendance, "today_attendance": attendance,
"today_conduct": conduct or {}, "today_conduct": conduct,
} }
async def homeroom_current_summary(current_user: Dict[str, Any]) -> Dict[str, Any]: async def homeroom_current_summary(current_user: Dict[str, Any]) -> Dict[str, Any]:
......
from bson import ObjectId
from app.db.mongodb import db
async def bind_user_to_student(user_id: str, student_id: str) -> bool:
uid = ObjectId(user_id)
exists = await db.db.students.find_one({"user_id": uid})
if exists:
return False
res = await db.db.students.update_one({"student_id": student_id}, {"$set": {"user_id": uid}})
return res.modified_count == 1
async def unbind_user_from_student(student_id: str) -> bool:
res = await db.db.students.update_one({"student_id": student_id}, {"$unset": {"user_id": ""}})
return res.modified_count == 1
async def get_student_by_user(user_id: str):
return await db.db.students.find_one({"user_id": ObjectId(user_id)})
\ No newline at end of file
...@@ -346,6 +346,7 @@ async def seed_school_data(db, mode: str): ...@@ -346,6 +346,7 @@ async def seed_school_data(db, mode: str):
# 索引 # 索引
await db.students.create_index("student_id", unique=True) await db.students.create_index("student_id", unique=True)
await db.students.create_index("class_id") await db.students.create_index("class_id")
await db.students.create_index("user_id", unique=True, sparse=True)
await db.classes.create_index("class_id", unique=True) await db.classes.create_index("class_id", unique=True)
await db.classes.create_index("head_teacher_id") await db.classes.create_index("head_teacher_id")
await db.schedules.create_index([("classes.class_id", 1), ("weekday", 1), ("period", 1)]) await db.schedules.create_index([("classes.class_id", 1), ("weekday", 1), ("period", 1)])
......
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