feat(init): 添加完整的认证和业务路由模块

- 实现微信登录、手机号绑定、token验证和登出功能
- 添加打卡系统,支持正常、迟到、请假等状态记录
- 集成课程投票功能,支持多课程选择和评分评论
- 开发反馈系统,支持图文反馈提交和管理员处理
- 构建首页页面模块,动态展示学习资源和个人信息
- 设计计划管理系统,支持周/月/年计划制定和跟踪
- 实现小组计划统计和历史记录查询功能
- 完善用户认证会话管理和数据库交互逻辑
This commit is contained in:
王宏豪
2026-09-03 16:43:11 +08:00
commit f3fbd0377a
24 changed files with 1918 additions and 0 deletions

474
routes/plan.py Normal file
View File

@@ -0,0 +1,474 @@
import json
import logging
import os
from datetime import date, datetime, time, timedelta
from typing import List
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from .. import config, db
from .auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/plan", tags=["plan"])
PLAN_TYPES = {"weekly", "monthly", "yearly"}
PLAN_STATUSES = {"已完成", "未完成"}
DEFAULT_STATUS = "未完成"
MAX_CONTENT_LEN = 1000
MAX_FILE_SIZE = 10 * 1024 * 1024
MAX_FILES = 4
ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
ATTACH_DIR = config.PLAN_ATTACH_DIR
SELECT_COLS = "id,user_id,plan_type,period_key,status,checked_at,content,attachments,created_at,updated_at"
CONTENT_TYPES = {
"pdf": "application/pdf", "png": "image/png", "jpg": "image/jpeg",
"jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp",
"bmp": "image/bmp", "doc": "application/msword",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
def iso_week_key(day):
iso = day.isocalendar()
return "%d-W%02d" % (iso[0], iso[1])
def parse_iso_week(period_key):
parts = period_key.split("-W")
if len(parts) != 2:
return None
try:
year = int(parts[0])
week = int(parts[1])
except ValueError as exc:
logger.warning("解析 ISO 周周期失败: %s", exc)
return None
jan4 = date(year, 1, 4)
monday = jan4 - timedelta(days=jan4.isoweekday() - 1)
return monday + timedelta(weeks=week - 1)
def default_weekly_period(today=None):
today = today or date.today()
if today.weekday() >= 1:
return iso_week_key(today + timedelta(days=7))
return iso_week_key(today)
def weekly_window(period_key):
monday = parse_iso_week(period_key)
if not monday:
return None, None
open_at = datetime.combine(monday - timedelta(days=3), time.min)
deadline = datetime.combine(monday + timedelta(days=1), time(23, 59, 59))
return open_at, deadline
def weekly_submit_state(period_key):
open_at, deadline = weekly_window(period_key)
if not open_at or not deadline:
return False, "无效的周周期"
now = datetime.now()
if now < open_at:
return False, "周五开始填写"
if now > deadline:
return False, "已过截止时间"
return True, ""
def get_period_key(plan_type):
today = date.today()
if plan_type == "weekly":
return default_weekly_period(today)
if plan_type == "monthly":
return today.strftime("%Y-%m")
if plan_type == "yearly":
return today.strftime("%Y")
return ""
def fmt_dt(value):
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return str(value) if value else ""
def _parse_attachments(raw):
if isinstance(raw, str):
try:
return json.loads(raw)
except Exception:
logger.exception("计划附件字段解析失败")
return []
return raw or []
def fmt_rec(rec):
return {
"id": rec["id"],
"user_id": rec["user_id"],
"plan_type": rec["plan_type"],
"period_key": rec["period_key"],
"status": rec.get("status", DEFAULT_STATUS),
"checked_at": fmt_dt(rec.get("checked_at")),
"content": rec.get("content", ""),
"attachments": _parse_attachments(rec.get("attachments")),
"created_at": fmt_dt(rec.get("created_at")),
"updated_at": fmt_dt(rec.get("updated_at")),
}
class PlanCheckinRequest(BaseModel):
plan_type: str = ""
period_key: str = ""
status: str = DEFAULT_STATUS
content: str = ""
attachments: List[str] = []
@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="仅可查询当前周期")
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),
)
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,
"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}
@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="小组不存在")
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):
"""Normalize to the current period for the given plan type.
Returns (selected, can_submit, message); raises 400 for invalid types/periods.
"""
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="无效的周周期")
if selected != default_weekly_period(date.today()):
raise HTTPException(status_code=400, detail="仅可查看当前周期")
can_submit, message = weekly_submit_state(selected)
else:
if selected != get_period_key(plan_type):
raise HTTPException(status_code=400, detail="仅可查看当前周期")
can_submit, message = (True, "")
return selected, can_submit, message
def _groups_stats(plan_type, selected):
"""单条 JOIN 聚合 SQL:提交计划即已完成,未提交即未完成。
按小组统计总人数 / 已完成(有记录)/ 未完成(无记录),避免 N+1 查询。
"""
rows = db.fetch_all(
"SELECT u.`group` AS group_name, "
"COUNT(u.id) AS total, "
"SUM(CASE WHEN pc.user_id IS NOT NULL THEN 1 ELSE 0 END) AS checked_count "
"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` <> '' "
"GROUP BY u.`group` ORDER BY u.`group`",
(plan_type, selected),
)
groups_list = []
for item in rows or []:
total = item.get("total") or 0
checked = item.get("checked_count") or 0
groups_list.append({
"group_name": item.get("group_name") or "",
"total": total,
"checked_count": checked,
"unchecked_count": total - checked,
})
return groups_list
@router.get("/groups")
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)
my_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, selected),
)
my_checked = my_rec is not None
my_status = (my_rec or {}).get("status", "")
groups_list = _groups_stats(type, selected)
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
return {
"type": type,
"period_key": selected,
"can_submit": can_submit,
"message": message,
"my_checked": my_checked,
"my_status": my_status,
"my_record": fmt_rec(my_rec) if my_rec else None,
"unassigned_count": unassigned_count,
"groups": groups_list,
}
@router.get("/group")
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)
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="小组不存在")
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=%s AND period_key=%s AND user_id IN (" + placeholders + ")",
tuple([type, 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 {
"type": type,
"period_key": selected,
"group_name": group_name,
"can_submit": can_submit,
"message": message,
"members": result,
}
@router.post("/checkin")
def checkin(req: PlanCheckinRequest, user=Depends(get_current_user)):
plan_type = req.plan_type.strip()
if plan_type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
period_key = req.period_key.strip() or get_period_key(plan_type)
if plan_type == "weekly":
if parse_iso_week(period_key) is None:
raise HTTPException(status_code=400, detail="无效的周周期")
default_period = default_weekly_period(date.today())
if period_key != default_period:
raise HTTPException(status_code=400, detail="仅可填写当前周期")
can_submit, message = weekly_submit_state(default_period)
if not can_submit:
raise HTTPException(status_code=400, detail=message)
status = req.status.strip()
if status not in PLAN_STATUSES:
status = DEFAULT_STATUS
content = req.content.strip()
if len(content) > MAX_CONTENT_LEN:
content = content[:MAX_CONTENT_LEN]
attachments = [a for a in req.attachments if isinstance(a, str) and a.strip()][:MAX_FILES]
attachments_json = json.dumps(attachments, ensure_ascii=False)
now = datetime.now()
db.execute(
"INSERT INTO plan_checkins (user_id, plan_type, period_key, status, checked_at, content, attachments) "
"VALUES (%s, %s, %s, %s, %s, %s, %s) "
"ON DUPLICATE KEY UPDATE status=VALUES(status), checked_at=VALUES(checked_at), "
"content=VALUES(content), attachments=VALUES(attachments)",
(user["id"], plan_type, period_key, status, now, content, attachments_json),
)
rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM plan_checkins WHERE user_id=%s AND plan_type=%s AND period_key=%s",
(user["id"], plan_type, period_key),
)
return {"success": True, "record": fmt_rec(rec)}
@router.get("/history")
def history(type: str, page: int = 1, size: int = 10, user=Depends(get_current_user)):
if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
page = max(1, page)
size = max(1, min(50, size))
offset = (page - 1) * size
total_row = db.fetch_one(
"SELECT COUNT(*) AS cnt FROM plan_checkins WHERE user_id=%s AND plan_type=%s",
(user["id"], type),
)
total = total_row["cnt"] if total_row else 0
rows = db.fetch_all(
"SELECT " + SELECT_COLS + " FROM plan_checkins WHERE user_id=%s AND plan_type=%s "
"ORDER BY period_key DESC LIMIT %s OFFSET %s",
(user["id"], type, size, offset),
)
return {"list": [fmt_rec(r) for r in rows], "total": total, "page": page, "size": size}
@router.post("/upload")
def upload(
plan_type: str = Form(...),
period_key: str = Form(""),
files: List[UploadFile] = File(...),
user=Depends(get_current_user),
):
plan_type = plan_type.strip()
if plan_type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
if len(files) > MAX_FILES:
raise HTTPException(status_code=400, detail="最多上传4个文件")
period_key = period_key.strip() or get_period_key(plan_type)
save_dir = os.path.join(ATTACH_DIR, str(user["id"]), plan_type, period_key)
os.makedirs(save_dir, exist_ok=True)
saved = []
for f in files:
data = f.file.read()
if len(data) > MAX_FILE_SIZE:
continue
safe_name = os.path.basename(f.filename or "")
if not safe_name:
continue
ext = os.path.splitext(safe_name)[1].lower()
if ext not in ALLOWED_IMAGE_EXT:
continue
save_path = os.path.join(save_dir, safe_name)
with open(save_path, "wb") as fh:
fh.write(data)
rel_path = "attachments/" + str(user["id"]) + "/" + plan_type + "/" + period_key + "/" + safe_name
saved.append(rel_path)
if not saved:
raise HTTPException(status_code=400, detail="文件保存失败")
return {"success": True, "attachments": saved}
@router.get("/file/{sid}/{plan_type}/{period}/{filename}")
def get_file(sid: str, plan_type: str, period: str, filename: str):
safe_name = os.path.basename(filename)
file_path = os.path.join(ATTACH_DIR, sid, plan_type, period, safe_name)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="not found")
ext = os.path.splitext(safe_name)[1].lstrip(".").lower()
media_type = CONTENT_TYPES.get(ext, "application/octet-stream")
return FileResponse(file_path, media_type=media_type)
@router.delete("/file/{sid}/{plan_type}/{period}/{filename}")
def delete_file(sid: str, plan_type: str, period: str, filename: str, user=Depends(get_current_user)):
if str(sid) != str(user["id"]):
raise HTTPException(status_code=403, detail="无权限")
safe_name = os.path.basename(filename)
file_path = os.path.join(ATTACH_DIR, sid, plan_type, period, safe_name)
if os.path.exists(file_path):
os.remove(file_path)
return {"success": True}