diff --git a/.env b/.env index 219607b..f5dbdd0 100644 --- a/.env +++ b/.env @@ -6,7 +6,7 @@ MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_USER=root MYSQL_PASSWORD=MingYuan20251112.!@# -MYSQL_DB=mys_info_v3 +MYSQL_DB=mys_info_test SURVEY_DB=myxhxc PHONE_HMAC_KEY=MingYuan2026AuthKey H5_BASE_URL=https://m.muguaclub.cn diff --git a/index.html b/index.html new file mode 100644 index 0000000..86b8608 --- /dev/null +++ b/index.html @@ -0,0 +1,258 @@ + + + + + + + +明缘进阶场 · 薪火相传 + + + +
+
+

明缘进阶场

+
+加载中... + +

+
+
+ +
+ +
+
🔥
+
+
薪火相传
+
年课 / 终身学习小组
+
+
+
+ + + + + + + + +
+
🎓
+
+
老师大班授权
+
年课 / 长期学员入口
+
+
+
+ + + +
🧬
学员360
薪火相传 · 个人成长档案
📊
量化课程
量化课程内容 · 随课程方上传更新
📚
读书会
经典原著 · 共读课程
+ + +
+
🧭
+
+
功能演示
+
先看看讨论和量化报告的样子
+
+
+
+
☯
易经学习
周易六十四卦 · 经文详解
+ + + +
+
📋
+
+
上课打卡
+
会员课程 · 小组讨论考勤
+
+
+
+ + +
+
✅
+
+
计划打卡
+
周计划 · 月计划 · 年计划
+
+
+
+
+ + +
+ + + + diff --git a/main.py b/main.py index 5eddc4f..9c53380 100644 --- a/main.py +++ b/main.py @@ -5,11 +5,10 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from fastapi import FastAPI -from server_prd.routes import auth, checkin, course_vote, feedback, pages, plan, profile, survey, webview, yijing +from server.routes import auth, checkin, course_vote, feedback, plan, profile, survey, webview, yijing app = FastAPI(title="明缘进阶场 API") app.include_router(auth.router) -app.include_router(pages.router) app.include_router(checkin.router) app.include_router(plan.router) app.include_router(profile.router) diff --git a/media/avatars/0005/avatar.jpeg b/media/avatars/0005/avatar.jpeg new file mode 100644 index 0000000..e50350c Binary files /dev/null and b/media/avatars/0005/avatar.jpeg differ diff --git a/migrations/001_add_wechat_openid_column.sql b/migrations/001_add_wechat_openid_column.sql new file mode 100644 index 0000000..6de4410 --- /dev/null +++ b/migrations/001_add_wechat_openid_column.sql @@ -0,0 +1,3 @@ +ALTER TABLE `auth_users` + ADD COLUMN `wechat_openid` VARCHAR(64) NULL DEFAULT NULL, + ADD UNIQUE KEY `uk_auth_users_wechat_openid` (`wechat_openid`); diff --git a/migrations/002_plan_perf_index.sql b/migrations/002_plan_perf_index.sql new file mode 100644 index 0000000..2dee172 --- /dev/null +++ b/migrations/002_plan_perf_index.sql @@ -0,0 +1,4 @@ +-- 002_plan_perf_index.sql +-- 计划小组总览按 (plan_type, period_key, student_id) 扫描,补齐索引以加速聚合查询。 +ALTER TABLE `plan_checkins` + ADD INDEX `idx_plan_period_student` (`plan_type`, `period_key`, `student_id`); diff --git a/migrations/003_checkin_records_user_id.sql b/migrations/003_checkin_records_user_id.sql new file mode 100644 index 0000000..38a851c --- /dev/null +++ b/migrations/003_checkin_records_user_id.sql @@ -0,0 +1,35 @@ +-- ========================================================= +-- 003: 打卡记录改为按 user_id 关联(user_id = auth_users.id) +-- 目标库:mys_info_test(正式库:mys_info,如需可改) +-- 前置:checkin_records_old 已有按 student_id 关联的旧数据 +-- 注意:执行前请先备份 checkin_records_old 表 +-- ========================================================= + +-- 1) 新增 user_id 列 +ALTER TABLE `checkin_records_old` + ADD COLUMN `user_id` BIGINT NULL DEFAULT NULL AFTER `id`; + +-- 2) 用 auth_users.id 按旧 student_id 回填历史记录的 user_id +UPDATE `checkin_records_old` r +LEFT JOIN `auth_users` u ON u.`student_id` = r.`student_id` +SET r.`user_id` = u.`id` +WHERE r.`user_id` IS NULL; + +-- 3) 确认旧 student_id 允许为 NULL +-- 新打卡代码只写 user_id,不再写 student_id +-- ⚠ 若原 student_id 实际类型不是 VARCHAR(64),请改成与原列一致的类型 +ALTER TABLE `checkin_records_old` + MODIFY COLUMN `student_id` VARCHAR(64) NULL DEFAULT NULL; + +-- 4) 唯一键改为 (user_id, class_date),供同用户同一天 upsert 使用 +ALTER TABLE `checkin_records_old` + ADD UNIQUE KEY `uk_checkin_user_date` (`user_id`, `class_date`); + + +-- ========================================================= +-- 以下为可选清理(按实际索引名执行) +-- 5) 旧版若存在 (student_id, class_date) 唯一键,确认不再需要后删除: +-- ALTER TABLE `checkin_records_old` DROP INDEX `uk_checkin_student_date`; +-- 6) 数据质量核查:检查是否有 student_id 无对应用户、user_id 仍为空的历史记录: +-- SELECT * FROM checkin_records_old WHERE user_id IS NULL; +-- ========================================================= diff --git a/migrations/004_plan_checkins_user_id.sql b/migrations/004_plan_checkins_user_id.sql new file mode 100644 index 0000000..b8147fb --- /dev/null +++ b/migrations/004_plan_checkins_user_id.sql @@ -0,0 +1,47 @@ +-- ========================================================= +-- 004: 计划记录改为按 user_id 关联(user_id = auth_users.id) +-- 目标库:mys_info_test(正式库:mys_info,如需可改) +-- 前置:plan_checkins 已有按 student_id 关联的旧数据 +-- 注意:执行前请先备份 plan_checkins 表 +-- ========================================================= + +-- 1) 新增 user_id 列 +ALTER TABLE `plan_checkins` + ADD COLUMN `user_id` BIGINT NULL DEFAULT NULL AFTER `id`; + +-- 2) 用 auth_users.id 按旧 student_id 回填历史记录的 user_id +UPDATE `plan_checkins` r +LEFT JOIN `auth_users` u ON u.`student_id` = r.`student_id` +SET r.`user_id` = u.`id` +WHERE r.`user_id` IS NULL; + +-- 3) 重写历史附件相对路径:attachments//... -> attachments//... +UPDATE `plan_checkins` r +JOIN `auth_users` u ON u.`student_id` = r.`student_id` +SET r.`attachments` = REPLACE(r.`attachments`, + CONCAT('attachments/', r.`student_id`, '/'), + CONCAT('attachments/', u.`id`, '/')) +WHERE r.`student_id` IS NOT NULL AND r.`user_id` IS NOT NULL; + +-- 4) 旧 student_id 允许为空;新代码只写 user_id +ALTER TABLE `plan_checkins` + MODIFY COLUMN `student_id` VARCHAR(64) NULL DEFAULT NULL; + +-- 5) 新增唯一键 (user_id, plan_type, period_key):同用户同周期 upsert +ALTER TABLE `plan_checkins` + ADD UNIQUE KEY `uk_plan_user_period` (`user_id`, `plan_type`, `period_key`); + +-- 6) 计划概览聚合索引改为按 user_id +ALTER TABLE `plan_checkins` + ADD INDEX `idx_plan_period_user` (`plan_type`, `period_key`, `user_id`); + + +-- ========================================================= +-- 以下为可选清理(按实际索引名执行) +-- 7) 旧版 (student_id, plan_type, period_key) 唯一键确认不再使用后: +-- ALTER TABLE `plan_checkins` DROP INDEX `uk_plan_student_period`; +-- 8) 旧版展平索引确认不再使用后: +-- ALTER TABLE `plan_checkins` DROP INDEX `idx_plan_period_student`; +-- 9) 数据质量核查:有无 student_id 无对应用户、user_id 仍为空的历史记录: +-- SELECT * FROM plan_checkins WHERE user_id IS NULL; +-- ========================================================= \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py index 1cfc34c..a37c9b7 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -1,5 +1,4 @@ import logging -import uuid from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Request @@ -53,7 +52,7 @@ def get_current_user(request: Request): def _record_session(user_id): """创建会话 token 并写入 auth_sessions。""" - token = uuid.uuid4().hex + uuid.uuid4().hex[:16] + token = security.create_session_token() expires = datetime.now() + timedelta(seconds=config.JWT_TTL_SECONDS) db.execute( "INSERT INTO auth_sessions (user_id, token, expires_at) VALUES (%s, %s, %s)", @@ -122,4 +121,3 @@ def logout(request: Request, user=Depends(get_current_user)): # 同时清除 webview cookie 登录态 resp.delete_cookie(key="auth_token", path="/") return resp - diff --git a/routes/checkin.py b/routes/checkin.py index 1efac6d..6d8b50f 100644 --- a/routes/checkin.py +++ b/routes/checkin.py @@ -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, }) diff --git a/routes/plan.py b/routes/plan.py index f74d9ea..8465740 100644 --- a/routes/plan.py +++ b/routes/plan.py @@ -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="缺少小组名") diff --git a/routes/profile.py b/routes/profile.py index f7f8aec..65aadc4 100644 --- a/routes/profile.py +++ b/routes/profile.py @@ -1,7 +1,6 @@ import os from fastapi import APIRouter, Depends, File, HTTPException, UploadFile -from pydantic import BaseModel from .. import config, db from ..user import user_payload @@ -19,7 +18,6 @@ def _profile(user): "name": payload["name"], "student_id": payload["student_id"], "display_name": payload["display_name"], - "wechat_nickname": user.get("wechat_nickname", ""), "phone": user.get("phone_display", "") or "", "group": payload["group"], "role": payload["role"], @@ -29,26 +27,11 @@ def _profile(user): } -class ProfileUpdateRequest(BaseModel): - wechat_nickname: str = "" - wechat_avatar: str = "" - - @router.get("") def get_profile(user=Depends(get_current_user)): return {"success": True, **_profile(user)} -@router.put("") -def update_profile(req: ProfileUpdateRequest, user=Depends(get_current_user)): - db.execute( - "UPDATE auth_users SET wechat_nickname=%s, wechat_avatar=%s WHERE id=%s", - (req.wechat_nickname, req.wechat_avatar, user["id"]), - ) - fresh = db.fetch_one("SELECT * FROM auth_users WHERE id=%s", (user["id"],)) - return {"success": True, **_profile(fresh or user)} - - @router.post("/avatar") def upload_avatar(file: UploadFile = File(...), user=Depends(get_current_user)): data = file.file.read() @@ -80,4 +63,3 @@ def upload_avatar(file: UploadFile = File(...), user=Depends(get_current_user)): db.execute("UPDATE auth_users SET wechat_avatar=%s WHERE id=%s", (url_path, user["id"])) return {"success": True, "wechat_avatar": url_path} - diff --git a/security.py b/security.py index b79cb47..0c4a367 100644 --- a/security.py +++ b/security.py @@ -2,9 +2,6 @@ import logging import uuid import hmac import hashlib -import time - -import jwt as pyjwt from . import config @@ -17,16 +14,6 @@ def encrypt_phone(phone): ).hexdigest() -def create_access_token(user_id): - now = int(time.time()) - payload = {"sub": str(user_id), "iat": now, "exp": now + config.JWT_TTL_SECONDS} - return pyjwt.encode(payload, config.JWT_SECRET, algorithm=config.JWT_ALGORITHM) - - -def decode_access_token(token): - return pyjwt.decode(token, config.JWT_SECRET, algorithms=[config.JWT_ALGORITHM]) - - def create_session_token(): """生成写入 auth_sessions 的会话 token(长度适合 token VARCHAR(64))。""" - return uuid.uuid4().hex + uuid.uuid4().hex[:16] \ No newline at end of file + return uuid.uuid4().hex + uuid.uuid4().hex[:16] diff --git a/start.sh b/start.sh index d105b4c..b184729 100644 --- a/start.sh +++ b/start.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # 明缘进阶场 FastAPI 启动脚本 (CentOS7, 固定路径) -cd /home/clock_server || exit 1 +cd /home/clock_server/clock_test || exit 1 -PID_FILE=/home/clock_server/logs/prd/server.pid -LOG_DIR=/home/clock_server/logs -OUT_LOG=/home/clock_server/logs/prd/server.out.log -ERR_LOG=/home/clock_server/logs/prd/server.err.log +PID_FILE=/home/clock_server/clock_test/logs/server.pid +LOG_DIR=/home/clock_server/clock_test/logs +OUT_LOG=/home/clock_server/clock_test/logs/server.out.log +ERR_LOG=/home/clock_server/clock_test/logs/server.err.log mkdir -p "$LOG_DIR" @@ -19,16 +19,16 @@ fi # 清理 Python 字节码缓存,确保加载最新代码 echo "清理 __pycache__..." -find /home/clock_server/server_prd -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null -find /home/clock_server/server_prd -type f -name "*.pyc" -delete 2>/dev/null +find /home/clock_server/clock_test -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null +find /home/clock_server/clock_test -type f -name "*.pyc" -delete 2>/dev/null echo "清理完成" -nohup /home/clock_server/pyenv39/bin/python -m uvicorn server_prd.main:app --host 0.0.0.0 --port 8001 >"$OUT_LOG" 2>"$ERR_LOG" & +nohup /home/clock_server/clock_test/pyenv39_test/bin/python -m uvicorn server.main:app --host 0.0.0.0 --port 8000 >"$OUT_LOG" 2>"$ERR_LOG" & echo $! > "$PID_FILE" sleep 1 if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then - echo "启动成功 PID $(cat "$PID_FILE") (http://0.0.0.0:8001)" + echo "启动成功 PID $(cat "$PID_FILE") (http://0.0.0.0:8000)" echo "日志: $OUT_LOG" echo "错误: $ERR_LOG" else diff --git a/start_prd.sh b/start_prd.sh new file mode 100644 index 0000000..d105b4c --- /dev/null +++ b/start_prd.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# 明缘进阶场 FastAPI 启动脚本 (CentOS7, 固定路径) +cd /home/clock_server || exit 1 + +PID_FILE=/home/clock_server/logs/prd/server.pid +LOG_DIR=/home/clock_server/logs +OUT_LOG=/home/clock_server/logs/prd/server.out.log +ERR_LOG=/home/clock_server/logs/prd/server.err.log + +mkdir -p "$LOG_DIR" + +if [ -f "$PID_FILE" ]; then + OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)" + if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then + echo "服务已在运行 (PID $OLD_PID)" + exit 1 + fi +fi + +# 清理 Python 字节码缓存,确保加载最新代码 +echo "清理 __pycache__..." +find /home/clock_server/server_prd -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null +find /home/clock_server/server_prd -type f -name "*.pyc" -delete 2>/dev/null +echo "清理完成" + +nohup /home/clock_server/pyenv39/bin/python -m uvicorn server_prd.main:app --host 0.0.0.0 --port 8001 >"$OUT_LOG" 2>"$ERR_LOG" & +echo $! > "$PID_FILE" + +sleep 1 +if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "启动成功 PID $(cat "$PID_FILE") (http://0.0.0.0:8001)" + echo "日志: $OUT_LOG" + echo "错误: $ERR_LOG" +else + echo "启动失败,请查看: $ERR_LOG" >&2 + exit 1 +fi \ No newline at end of file diff --git a/stop.sh b/stop.sh index 60ed700..7f040b1 100644 --- a/stop.sh +++ b/stop.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # 明缘进阶场 FastAPI 停止脚本 (CentOS7, 固定路径) -PID_FILE=/home/clock_server/logs/prd/server.pid -PORT=8001 +PID_FILE=/home/clock_server/clock_test/logs/server.pid +PORT=8000 kill_pid() { local pid="$1" @@ -26,7 +26,7 @@ if [ -f "$PID_FILE" ]; then fi # 2) 按命令行兜底(uvicorn) -for p in $(pgrep -f "uvicorn server_prd.main:app" 2>/dev/null || true); do +for p in $(pgrep -f "uvicorn server.main:app" 2>/dev/null || true); do kill_pid "$p" done diff --git a/stop_prd.sh b/stop_prd.sh new file mode 100644 index 0000000..60ed700 --- /dev/null +++ b/stop_prd.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# 明缘进阶场 FastAPI 停止脚本 (CentOS7, 固定路径) +PID_FILE=/home/clock_server/logs/prd/server.pid +PORT=8001 + +kill_pid() { + local pid="$1" + [ -z "$pid" ] && return 0 + echo "停止 PID $pid" + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 30); do + if ! kill -0 "$pid" 2>/dev/null; then break; fi + sleep 0.5 + done + if kill -0 "$pid" 2>/dev/null; then + echo "进程未退出,强制结束 PID $pid" + kill -9 "$pid" 2>/dev/null || true + fi +} + +# 1) 按 PID 文件 +if [ -f "$PID_FILE" ]; then + PID="$(cat "$PID_FILE" 2>/dev/null || true)" + kill_pid "$PID" + rm -f "$PID_FILE" +fi + +# 2) 按命令行兜底(uvicorn) +for p in $(pgrep -f "uvicorn server_prd.main:app" 2>/dev/null || true); do + kill_pid "$p" +done + +# 3) 按端口兜底:杀掉占用 8001 的任何进程 +FUSER_BIN="$(command -v fuser || true)" +if [ -n "$FUSER_BIN" ]; then + echo "检查并释放端口 $PORT" + $FUSER_BIN -k "$PORT/tcp" 2>/dev/null || true + $FUSER_BIN -k -n tcp "$PORT" 2>/dev/null || true +fi +sleep 1 + +echo "完成" \ No newline at end of file