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

@@ -56,7 +56,9 @@ def parse_iso_week(period_key):
def default_weekly_period(today=None):
today = today or date.today()
if today.weekday() >= 1:
# 周五及以后(周五、周六、周日)默认填写下周计划
# 周一到周四默认填写本周计划
if today.weekday() >= 4:
return iso_week_key(today + timedelta(days=7))
return iso_week_key(today)
@@ -65,8 +67,10 @@ def weekly_window(period_key):
monday = parse_iso_week(period_key)
if not monday:
return None, None
# 开启:目标周的上一周周五 00:00
open_at = datetime.combine(monday - timedelta(days=3), time.min)
deadline = datetime.combine(monday + timedelta(days=1), time(23, 59, 59))
# 截止:目标周的周四 23:59:59
deadline = datetime.combine(monday + timedelta(days=3), time(23, 59, 59))
return open_at, deadline
@@ -78,10 +82,87 @@ def weekly_submit_state(period_key):
if now < open_at:
return False, "周五开始填写"
if now > deadline:
return False, "已过截止时间"
return False, "周四截止,已过提交时间"
return True, ""
PERIOD_START_YEAR = 2022
def monthly_submit_state(period_key):
"""月计划提交状态:当前月内可提交,历史月/未来月均不可提交。"""
if not _validate_month_key(period_key):
return False, "无效的月周期"
today = date.today()
year, month = period_key.split("-")
period_year, period_month = int(year), int(month)
if period_year < today.year or (period_year == today.year and period_month < today.month):
return False, "已过提交时间"
if period_year > today.year or (period_year == today.year and period_month > today.month):
return False, "未到提交时间"
return True, ""
def yearly_submit_state(period_key):
"""年计划提交状态:当前年内可提交,往年/未来年均不可提交。"""
if not _validate_year_key(period_key):
return False, "无效的年周期"
today = date.today()
period_year = int(period_key)
if period_year < today.year:
return False, "已过提交时间"
if period_year > today.year:
return False, "未到提交时间"
return True, ""
def generate_period_list(plan_type, start_year: int = PERIOD_START_YEAR):
"""
生成从 start_year 到当前的所有自然周期列表,倒序排列(最新在前)。
- weekly: ISO 周格式 YYYY-Www
- monthly: YYYY-MM
- yearly: YYYY
"""
today = date.today()
if plan_type == "weekly":
weeks = []
# 从 start_year 第一个 ISO 周开始
jan4 = date(start_year, 1, 4)
first_monday = jan4 - timedelta(days=jan4.isoweekday() - 1)
# 截止到当前 ISO 周
iso_today = today.isocalendar()
last_jan4 = date(iso_today[0], 1, 4)
last_monday = last_jan4 - timedelta(days=last_jan4.isoweekday() - 1)
end_monday = last_monday + timedelta(weeks=iso_today[1] - 1)
current = first_monday
while current <= end_monday:
weeks.append(iso_week_key(current))
current += timedelta(weeks=1)
weeks.reverse()
return weeks
if plan_type == "monthly":
months = []
for y in range(start_year, today.year + 1):
for m in range(1, 13):
if y == today.year and m > today.month:
break
months.append("%04d-%02d" % (y, m))
months.reverse()
return months
if plan_type == "yearly":
years = []
for y in range(start_year, today.year + 1):
years.append(str(y))
years.reverse()
return years
return []
def get_period_key(plan_type):
today = date.today()
if plan_type == "weekly":
@@ -132,93 +213,95 @@ class PlanCheckinRequest(BaseModel):
attachments: List[str] = []
@router.get("/periods")
def periods(type: str, user=Depends(get_current_user)):
"""
返回指定计划类型下的全部自然周期列表(从 2022 年到当前,倒序)。
用于前端做周期切换选择器。
"""
if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
period_list = generate_period_list(type)
current = get_period_key(type)
return {
"type": type,
"periods": period_list,
"current_period": current,
}
@router.get("/status")
def status(type: str, period_key: str = "", user=Depends(get_current_user)):
if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
period_key = period_key.strip() or get_period_key(type)
if type == "weekly":
if parse_iso_week(period_key) is None:
raise HTTPException(status_code=400, detail="无效的周周期")
if period_key != default_weekly_period(date.today()):
raise HTTPException(status_code=400, detail="仅可查询当前周期")
selected, can_submit, message = _normalize_period(type, period_key)
rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM plan_checkins WHERE user_id=%s AND plan_type=%s AND period_key=%s",
(user["id"], type, period_key),
(user["id"], type, selected),
)
can_submit, message = (True, "")
if type == "weekly":
can_submit, message = weekly_submit_state(period_key)
return {"plan_type": type, "period_key": period_key, "checked": rec is not None,
return {"plan_type": type, "period_key": selected, "checked": rec is not None,
"can_submit": can_submit, "message": message,
"record": fmt_rec(rec) if rec else None}
@router.get("/weekly-groups")
def weekly_groups(period_key: str = "", user=Depends(get_current_user)):
selected, _can_submit, _message = _validate_current_period("weekly", period_key)
stats = _groups_stats("weekly", selected)
groups = [
{
"group_name": g["group_name"],
"checked_count": g["checked_count"],
"unchecked_count": g["unchecked_count"],
"total": g["total"],
}
for g in stats
]
return {"period_key": selected, "groups": groups}
def _is_teacher(user):
"""判断用户是否为老师角色。"""
return (user.get("role") or "").strip().lower() == "teacher"
@router.get("/weekly-group")
def weekly_group(group_name: str = "", period_key: str = "", user=Depends(get_current_user)):
default_period = default_weekly_period(date.today())
selected = period_key.strip() or default_period
if selected != default_period:
raise HTTPException(status_code=400, detail="仅可查看当前周期")
group_name = group_name.strip()
if not group_name:
raise HTTPException(status_code=400, detail="缺少小组名")
group_row = db.fetch_one("SELECT `group` FROM auth_users WHERE `group`=%s LIMIT 1", (group_name,))
if not group_row:
raise HTTPException(status_code=404, detail="小组不存在")
def _validate_month_key(period_key: str) -> bool:
"""校验月周期格式 YYYY-MM。"""
if not period_key:
return False
parts = period_key.split("-")
if len(parts) != 2:
return False
try:
year = int(parts[0])
month = int(parts[1])
return 2020 <= year <= 2100 and 1 <= month <= 12
except ValueError:
return False
def _validate_year_key(period_key: str) -> bool:
"""校验年周期格式 YYYY。"""
if not period_key or len(period_key) != 4:
return False
try:
year = int(period_key)
return 2020 <= year <= 2100
except ValueError:
return False
def _normalize_period(plan_type, period_key):
"""
规范化周期 key,做基础校验(不限制必须是当前周期)。
用于查看类接口:允许查看任意历史周期。
Returns (selected, can_submit, message); 校验失败抛出 400。
"""
selected = period_key.strip() or get_period_key(plan_type)
if plan_type == "weekly":
if parse_iso_week(selected) is None:
raise HTTPException(status_code=400, detail="无效的周周期")
can_submit, message = weekly_submit_state(selected)
elif plan_type == "monthly":
if not _validate_month_key(selected):
raise HTTPException(status_code=400, detail="无效的月周期")
can_submit, message = monthly_submit_state(selected)
elif plan_type == "yearly":
if not _validate_year_key(selected):
raise HTTPException(status_code=400, detail="无效的年周期")
can_submit, message = yearly_submit_state(selected)
else:
can_submit, message = (True, "")
return selected, can_submit, message
can_submit, message = weekly_submit_state(default_period)
members = db.fetch_all(
"SELECT id AS user_id, name FROM auth_users WHERE `group`=%s ORDER BY id",
(group_name,),
)
uids = [m["user_id"] for m in members]
rec_map = {}
if uids:
placeholders = ",".join(["%s"] * len(uids))
records = db.fetch_all(
"SELECT user_id, status, checked_at FROM plan_checkins "
"WHERE plan_type='weekly' AND period_key=%s AND user_id IN (" + placeholders + ")",
tuple([selected] + uids),
)
rec_map = {r["user_id"]: r for r in records}
result = []
for m in members:
rec = rec_map.get(m["user_id"])
result.append({
"user_id": m["user_id"],
"name": m["name"],
"filled": rec is not None,
"record": {
"status": rec["status"],
"checked_at": fmt_dt(rec.get("checked_at")),
} if rec else None,
})
result.sort(key=lambda item: (0 if not item["filled"] else 1, item["user_id"]))
return {
"period_key": selected,
"group_name": group_name,
"can_submit": can_submit,
"message": message,
"members": result,
}
def _validate_current_period(plan_type, period_key):
@@ -240,11 +323,23 @@ def _validate_current_period(plan_type, period_key):
return selected, can_submit, message
def _groups_stats(plan_type, selected):
def _groups_stats(plan_type, selected, group_filter=None):
"""单条 JOIN 聚合 SQL:提交计划即已完成,未提交即未完成。
按小组统计总人数 / 已完成(有记录)/ 未完成(无记录),避免 N+1 查询。
group_filter: 只统计指定小组
"""
where_clauses = []
params = [plan_type, selected]
if group_filter:
where_clauses.append("u.`group` = %s")
params.append(group_filter)
else:
where_clauses.append("u.`group` IS NOT NULL AND u.`group` <> ''")
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
rows = db.fetch_all(
"SELECT u.`group` AS group_name, "
"COUNT(u.id) AS total, "
@@ -252,9 +347,9 @@ def _groups_stats(plan_type, selected):
"FROM auth_users u "
"LEFT JOIN plan_checkins pc ON pc.user_id = u.id "
"AND pc.plan_type = %s AND pc.period_key = %s "
"WHERE u.`group` IS NOT NULL AND u.`group` <> '' "
+ where_sql + " "
"GROUP BY u.`group` ORDER BY u.`group`",
(plan_type, selected),
tuple(params),
)
groups_list = []
for item in rows or []:
@@ -273,7 +368,7 @@ def _groups_stats(plan_type, selected):
def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
selected, can_submit, message = _validate_current_period(type, period_key)
selected, can_submit, message = _normalize_period(type, period_key)
my_rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM plan_checkins "
@@ -283,17 +378,30 @@ def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
my_checked = my_rec is not None
my_status = (my_rec or {}).get("status", "")
groups_list = _groups_stats(type, selected)
is_teacher = _is_teacher(user)
my_group = (user.get("group") or "").strip()
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
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
if is_teacher:
# 老师:查看所有小组
groups_list = _groups_stats(type, selected)
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(type, selected, 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 {
"type": type,
@@ -303,6 +411,8 @@ def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
"my_checked": my_checked,
"my_status": my_status,
"my_record": fmt_rec(my_rec) if my_rec else None,
"is_teacher": is_teacher,
"my_group": my_group,
"unassigned_count": unassigned_count,
"groups": groups_list,
}
@@ -312,7 +422,7 @@ def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
def group(type: str, group_name: str, period_key: str = "", user=Depends(get_current_user)):
if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
selected, can_submit, message = _validate_current_period(type, period_key)
selected, can_submit, message = _normalize_period(type, period_key)
group_name = group_name.strip()
if not group_name:
raise HTTPException(status_code=400, detail="缺少小组名")