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 @@
+
+
+
+
+
+
+
+明缘进阶场 · 薪火相传
+
+
+
+
+
+
+
+
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