feat(database): 迁移用户关联字段并优化打卡系统

- 修改数据库配置将MYSQL_DB从mys_info_v3改为mys_info_test
- 新增auth_users表wechat_openid字段并建立唯一索引
- 为plan_checkins表添加计划性能索引(idx_plan_period_student)
- 将checkin_records_old表关联方式从student_id改为user_id
- 将plan_checkins表关联方式从student_id改为user_id并更新附件路径
- 移除routes/auth.py中的uuid导入使用安全token生成函数
- 重构routes/checkin.py打卡逻辑去除course_type字段并优化返回结构
- 添加打卡时间限制验证功能防止过期打卡操作
- 实现小组统计功能支持老师查看各组打卡情况
- 更新主页index.html界面适配新的打卡和计划功能
- 调整计划提交时间规则为周五至周四周期
- 添加月度和年度计划提交状态验证机制
This commit is contained in:
王宏豪
2026-09-10 14:11:59 +08:00
parent 4fbcff2bb4
commit 9a7681112b
17 changed files with 876 additions and 168 deletions

View File

@@ -15,15 +15,51 @@ router = APIRouter(prefix="/api/v1/checkin", tags=["checkin"])
CHECKIN_STATUSES = {"正常", "迟到", "请假", "不参加讨论", "早退"}
DEFAULT_STATUS = "正常"
DEFAULT_COURSE_TYPE = "会员课程"
MAX_NOTE_LEN = 500
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
SELECT_COLS = "id,user_id,class_date,checked_at,status,course_type,note"
SELECT_COLS = "id,user_id,class_date,checked_at,status,note"
# 周期列表起始年份
PERIOD_START_YEAR = 2022
def get_class_date():
def get_class_date(ref_date=None):
"""获取指定日期所在周的周六日期(上课日)。"""
today = ref_date or date.today()
return today + timedelta(days=(5 - today.weekday()))
def get_saturday_of_week(week_monday: date) -> date:
"""给定某周周一,返回该周周六。"""
return week_monday + timedelta(days=5)
def generate_week_list(start_year: int = PERIOD_START_YEAR) -> list:
"""
生成从 start_year 到今天的所有上课周(每周六)列表,倒序排列。
返回格式: ["2026-09-19", "2026-09-12", ...]
"""
today = date.today()
return today + timedelta(days=(6 - today.weekday()))
# 找到 start_year 第一个周六
# 从1月1日往前找最近的周六,再往后逐周生成
d = date(start_year, 1, 1)
# 调整到第一个周六
days_until_saturday = (5 - d.weekday()) % 7
if days_until_saturday == 0 and d.weekday() != 5:
days_until_saturday = 0
first_saturday = d + timedelta(days=days_until_saturday)
# 确保第一个周六在 start_year 内
if first_saturday.year < start_year:
first_saturday += timedelta(days=7)
weeks = []
current = first_saturday
while current <= today + timedelta(days=7): # 多生成一周
weeks.append(current.strftime("%Y-%m-%d"))
current += timedelta(days=7)
weeks.reverse() # 倒序,最新的在前
return weeks
def _is_valid_date(value):
@@ -42,29 +78,94 @@ def fmt_dt(value):
return str(value) if value else ""
def fmt_rec(rec, name=""):
def fmt_rec(rec):
return {
"id": rec["id"],
"user_id": rec["user_id"],
"name": name,
"class_date": str(rec["class_date"]),
"checked_at": fmt_dt(rec.get("checked_at")),
"status": rec.get("status", DEFAULT_STATUS),
"course_type": rec.get("course_type", DEFAULT_COURSE_TYPE),
"note": rec.get("note", ""),
}
def _is_teacher(user):
"""判断当前用户是否为老师角色。"""
return (user.get("role") or "").strip().lower() == "teacher"
def _groups_stats(class_date, group_filter=None):
"""
统计各小组在指定上课日的打卡情况。
返回 [{group_name, checked_count, total}],按 group_name 排序。
group_filter: 只统计指定小组
"""
where_clauses = []
params = []
if group_filter:
where_clauses.append("m.`group` = %s")
params.append(group_filter)
else:
where_clauses.append("m.`group` IS NOT NULL AND m.`group` != ''")
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
rows = db.fetch_all(
"SELECT m.`group` AS group_name, "
" COUNT(*) AS total, "
" SUM(CASE WHEN r.id IS NOT NULL THEN 1 ELSE 0 END) AS checked_count "
"FROM auth_users m "
"LEFT JOIN checkin_records_old r "
" ON r.user_id = m.id AND r.class_date = %s "
+ where_sql + " "
"GROUP BY m.`group` "
"ORDER BY m.`group`",
(class_date, *params),
)
return [
{
"group_name": r["group_name"] or "",
"total": r["total"] or 0,
"checked_count": r["checked_count"] or 0,
}
for r in rows
]
class CheckinRequest(BaseModel):
status: str = DEFAULT_STATUS
class_date: str = ""
note: str = ""
course_type: str = DEFAULT_COURSE_TYPE
@router.get("/weeks")
def weeks(user=Depends(get_current_user)):
"""
返回所有上课周列表(每周六日期),从 2022 年到当前,倒序排列。
用于前端做历史周切换选择器。
"""
week_list = generate_week_list()
current = str(get_class_date())
return {
"weeks": week_list,
"current_week": current,
}
@router.get("/week")
def week(user=Depends(get_current_user)):
class_date = str(get_class_date())
def week(class_date: str = "", user=Depends(get_current_user)):
"""
[已弃用] 获取指定上课日的个人打卡状态。
请使用 /groups 接口,其返回值已包含 checked + record。
保留此接口仅为兼容旧客户端。
"""
if class_date:
class_date = class_date.strip()
if not _is_valid_date(class_date):
raise HTTPException(status_code=400, detail="无效的上课日期")
else:
class_date = str(get_class_date())
rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
(user["id"], class_date),
@@ -72,6 +173,83 @@ def week(user=Depends(get_current_user)):
return {"class_date": class_date, "checked": rec is not None, "record": fmt_rec(rec) if rec else None}
@router.get("/groups")
def groups_view(class_date: str = "", user=Depends(get_current_user)):
"""
老师:返回所有小组统计 + 个人状态
学员:返回自己所在小组统计 + 个人状态
"""
if class_date:
class_date = class_date.strip()
if not _is_valid_date(class_date):
raise HTTPException(status_code=400, detail="无效的上课日期")
else:
class_date = str(get_class_date())
# 个人打卡状态
rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
(user["id"], class_date),
)
my_checked = rec is not None
my_record = fmt_rec(rec) if rec else None
is_teacher = _is_teacher(user)
my_group = (user.get("group") or "").strip()
if is_teacher:
groups_list = _groups_stats(class_date)
# 自己所在小组排最前
if my_group:
mine = [g for g in groups_list if g["group_name"] == my_group]
others = [g for g in groups_list if g["group_name"] != my_group]
groups_list = mine + others
else:
# 学员只看自己小组
if not my_group:
groups_list = []
else:
groups_list = _groups_stats(class_date, group_filter=my_group)
# 未分组人数(仅老师可见)
unassigned_count = 0
if is_teacher:
unassigned_row = db.fetch_one(
"SELECT COUNT(*) AS cnt FROM auth_users WHERE `group` IS NULL OR `group` = ''"
)
unassigned_count = unassigned_row["cnt"] if unassigned_row else 0
return {
"class_date": class_date,
"checked": my_checked,
"record": my_record,
"is_teacher": is_teacher,
"my_group": my_group,
"groups": groups_list,
"unassigned_count": unassigned_count,
}
def _can_checkin_date(class_date_str):
"""判断指定上课日是否还可以打卡。
规则:上课日所在自然周(周一~周日)与当前周相同,或为未来周,则可以打卡。
上课日是周日,同一自然周内(周一到周日)都可以打卡、可以修改。
"""
try:
class_date = datetime.strptime(class_date_str, "%Y-%m-%d").date()
except ValueError:
return False, "无效的日期"
today = date.today()
# 找到上课日所在周的周一
class_monday = class_date - timedelta(days=class_date.isoweekday() - 1)
# 找到今天所在周的周一
today_monday = today - timedelta(days=today.isoweekday() - 1)
if class_monday < today_monday:
return False, "已过打卡时间"
return True, ""
@router.post("")
def checkin(req: CheckinRequest, user=Depends(get_current_user)):
status = req.status.strip()
@@ -82,6 +260,10 @@ def checkin(req: CheckinRequest, user=Depends(get_current_user)):
if not _is_valid_date(class_date):
raise HTTPException(status_code=400, detail="无效的上课日期")
can_submit, msg = _can_checkin_date(class_date)
if not can_submit:
raise HTTPException(status_code=400, detail=msg)
note = req.note.strip() if status != DEFAULT_STATUS else ""
if len(note) > MAX_NOTE_LEN:
raise HTTPException(status_code=400, detail="备注过长")
@@ -94,21 +276,26 @@ def checkin(req: CheckinRequest, user=Depends(get_current_user)):
)
if existing:
db.execute(
"UPDATE checkin_records_old SET checked_at=%s, status=%s, course_type=%s, note=%s WHERE id=%s",
(now, status, DEFAULT_COURSE_TYPE, note, existing["id"]),
"UPDATE checkin_records_old SET checked_at=%s, status=%s, note=%s WHERE id=%s",
(now, status, note, existing["id"]),
)
record_id = existing["id"]
else:
# course_type 固定为会员课程(规范),忽略客户端传入值
db.execute(
"INSERT INTO checkin_records_old (user_id, class_date, checked_at, status, course_type, note) "
"VALUES (%s, %s, %s, %s, %s, %s)",
(user["id"], class_date, now, status, DEFAULT_COURSE_TYPE, note),
record_id = db.execute(
"INSERT INTO checkin_records_old (user_id, class_date, checked_at, status, note) "
"VALUES (%s, %s, %s, %s, %s)",
(user["id"], class_date, now, status, note),
)
rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
(user["id"], class_date),
)
return {"success": True, "record": fmt_rec(rec, user.get("name", "")) if rec else None}
# 直接构造返回,省一次 SELECT
record = {
"id": record_id,
"user_id": user["id"],
"class_date": class_date,
"status": status,
"checked_at": fmt_dt(now),
"note": note,
}
return {"success": True, "record": record}
@router.get("/list")
@@ -125,24 +312,42 @@ def list_records(page: int = 1, size: int = 10, user=Depends(get_current_user)):
"WHERE user_id=%s ORDER BY class_date DESC LIMIT %s OFFSET %s",
(user["id"], size, offset),
)
return {"list": [fmt_rec(r, user.get("name", "")) for r in rows], "total": total, "page": page, "size": size}
return {"list": [fmt_rec(r) for r in rows], "total": total, "page": page, "size": size}
@router.get("/group")
def group_view(class_date: str = "", user=Depends(get_current_user)):
def group_view(class_date: str = "", group_name: str = "", user=Depends(get_current_user)):
"""
查看某小组的打卡成员列表。
- 老师:可以传 group_name 查看任意小组;不传则看自己所在小组
- 学员:只能看自己所在小组,传其他小组名返回 403
"""
class_date = class_date.strip() or str(get_class_date())
if not _is_valid_date(class_date):
raise HTTPException(status_code=400, detail="无效的上课日期")
user_row = db.fetch_one("SELECT `group` FROM auth_users WHERE id=%s", (user["id"],))
if not user_row or not user_row.get("group"):
my_group = (user.get("group") or "").strip()
is_teacher = _is_teacher(user)
if group_name:
group_name = group_name.strip()
# 权限校验:学员只能看自己小组
if not is_teacher and group_name != my_group:
raise HTTPException(status_code=403, detail="无权限查看其他小组")
else:
group_name = my_group
if not group_name:
return {"class_date": class_date, "group_name": "", "members": []}
group_name = user_row["group"]
# 校验小组是否存在
g_row = db.fetch_one("SELECT `group` FROM auth_users WHERE `group`=%s LIMIT 1", (group_name,))
if not g_row:
return {"class_date": class_date, "group_name": group_name, "members": []}
# 单查询:成员 + 同组成员该上课日的打卡记录,LEFT JOIN 一次取回
rows = db.fetch_all(
"SELECT m.id AS user_id, m.student_id, m.name, "
" r.status, r.checked_at, r.note "
" r.id AS record_id, r.status, r.checked_at, r.note "
"FROM auth_users m "
"LEFT JOIN checkin_records_old r "
" ON r.user_id = m.id AND r.class_date = %s "
@@ -156,6 +361,8 @@ def group_view(class_date: str = "", user=Depends(get_current_user)):
rec = None
if row.get("status") is not None:
rec = {
"id": row["record_id"],
"user_id": row["user_id"],
"status": row["status"],
"checked_at": fmt_dt(row.get("checked_at")),
"note": row.get("note", ""),
@@ -164,7 +371,6 @@ def group_view(class_date: str = "", user=Depends(get_current_user)):
"user_id": row["user_id"],
"student_id": row["student_id"],
"name": row["name"],
"role": "",
"checked": rec is not None,
"record": rec,
})