- 实现微信登录、手机号绑定、token验证和登出功能 - 添加打卡系统,支持正常、迟到、请假等状态记录 - 集成课程投票功能,支持多课程选择和评分评论 - 开发反馈系统,支持图文反馈提交和管理员处理 - 构建首页页面模块,动态展示学习资源和个人信息 - 设计计划管理系统,支持周/月/年计划制定和跟踪 - 实现小组计划统计和历史记录查询功能 - 完善用户认证会话管理和数据库交互逻辑
475 lines
17 KiB
Python
475 lines
17 KiB
Python
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}
|