Files
wechat_backend/routes/plan.py
王宏豪 65ec4e6a52 feat(backend): 调整课程日期为周日并优化计划提交时间窗口
- 将上课日从周六调整为周日,新增 CLASS_WEEKDAY 常量
- 更新 get_class_date 和 get_sunday_of_week 函数以支持周日计算
- 修改 generate_week_list 函数生成周日日期而非周六日期
- 重构月计划和年计划的时间窗口逻辑,增加提前提交功能
- 新增 monthly_window 和 yearly_window 函数定义准确的提交窗口期
- 调整默认周期选择逻辑,支持跨月年的时间窗口
- 添加 period_labels 字段提供前端周期标签显示
- 移除旧的 _validate_current_period 函数
- 完善计划提交验证逻辑,支持多种时间周期类型

chore(miniapp): 更新小程序项目配置

- 补充 project.config.json 中的设置选项
- 调整 project.private.config.json 配置结构
- 移除冗余的页面调试条件配置
- 更新编辑器和编译相关配置项
2026-09-11 17:51:24 +08:00

694 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() >= 4:
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
# 开启:目标周的上一周周五 00:00
open_at = datetime.combine(monday - timedelta(days=3), time.min)
# 截止:目标周的周四 23:59:59
deadline = datetime.combine(monday + timedelta(days=3), 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, ""
PERIOD_START_YEAR = 2022
def parse_month_key(period_key):
"""把月周期 key 解析为该月 1 日;非法返回 None。"""
if not _validate_month_key(period_key):
return None
year, month = period_key.split("-")
return date(int(year), int(month), 1)
def monthly_window(period_key):
"""月计划填写窗口:上个月最后 5 天 00:00 ~ 本月第 10 天 23:59:59。
例:2026-09 的窗口 = 2026-08-27 00:00 ~ 2026-09-10 23:59:59。
"""
first_day = parse_month_key(period_key)
if not first_day:
return None, None
prev_month_last = first_day - timedelta(days=1) # 上个月最后一天
open_at = datetime.combine(prev_month_last - timedelta(days=4), time.min)
deadline = datetime.combine(first_day + timedelta(days=9), time(23, 59, 59))
return open_at, deadline
def yearly_window(period_key):
"""年计划填写窗口:上一年 12 月 1 日 00:00 ~ 本年 1 月 31 日 23:59:59。
例:2026 的窗口 = 2025-12-01 00:00 ~ 2026-01-31 23:59:59。
"""
if not _validate_year_key(period_key):
return None, None
year = int(period_key)
open_at = datetime(year - 1, 12, 1, 0, 0, 0)
deadline = datetime(year, 1, 31, 23, 59, 59)
return open_at, deadline
def monthly_submit_state(period_key):
"""月计划提交状态:窗口内(上月最后 5 天 ~ 本月第 10 天)可提交,其余时间不可。"""
open_at, deadline = monthly_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 yearly_submit_state(period_key):
"""年计划提交状态:窗口内(上一年 12 月 ~ 本年 1 月)可提交,其余时间不可。"""
open_at, deadline = yearly_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 generate_period_list(plan_type, start_year: int = PERIOD_START_YEAR):
"""
生成从 start_year 到当前的所有自然周期列表,倒序排列(最新在前)。
- weekly: ISO 周格式 YYYY-Www
- monthly: YYYY-MM
- yearly: YYYY
三种类型的终点统一取「当前应填周期」而非「今天所在周期」:周计划周五起填下一周,
月计划上月末 5 天起填下一月,年计划上年 12 月起填下一年。终点若不随之延伸,
current_period 会落在列表之外,前端选不到、也就无法提交。
"""
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)
# 截止到「当前应填周期」(周一~周四为本周期,周五~周日为下一周期)
end_monday = parse_iso_week(default_weekly_period(today))
if end_monday is None: # 兜底:理论上不会发生
iso_today = today.isocalendar()
base_jan4 = date(iso_today[0], 1, 4)
end_monday = (base_jan4 - timedelta(days=base_jan4.isoweekday() - 1)
+ 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 = []
# 终点取「当前应填周期」(窗口开放月,可能是下个月)
end_key = default_monthly_period(today)
end_year, end_month = int(end_key[:4]), int(end_key[5:7])
for y in range(start_year, end_year + 1):
for m in range(1, 13):
if y == end_year and m > end_month:
break
months.append("%04d-%02d" % (y, m))
months.reverse()
return months
if plan_type == "yearly":
years = []
# 终点取「当前应填周期」(窗口开放年,可能是下一年)
end_year = int(default_yearly_period(today))
for y in range(start_year, end_year + 1):
years.append(str(y))
years.reverse()
return years
return []
def _month_keys_around(today):
"""返回 (上上月 … 下下月) 的月周期 key,按时间升序。"""
keys = []
for offset in (-2, -1, 0, 1, 2):
total = today.year * 12 + (today.month - 1) + offset
year, month = divmod(total, 12)
keys.append("%04d-%02d" % (year, month + 1))
return keys
def default_monthly_period(today=None):
"""月计划应填周期:窗口正在开放的那个月;都不开放时取最近将要开放的月。"""
today = today or date.today()
now = datetime.now()
next_open = None
for key in _month_keys_around(today):
open_at, deadline = monthly_window(key)
if not open_at:
continue
if open_at <= now <= deadline:
return key
if open_at > now and (next_open is None or open_at < next_open[0]):
next_open = (open_at, key)
if next_open:
return next_open[1]
return "%04d-%02d" % (today.year, today.month)
def default_yearly_period(today=None):
"""年计划应填周期:窗口正在开放的那一年;都不开放时取最近将要开放的年。"""
today = today or date.today()
now = datetime.now()
next_open = None
for key in (str(today.year - 1), str(today.year), str(today.year + 1), str(today.year + 2)):
open_at, deadline = yearly_window(key)
if not open_at:
continue
if open_at <= now <= deadline:
return key
if open_at > now and (next_open is None or open_at < next_open[0]):
next_open = (open_at, key)
if next_open:
return next_open[1]
return str(today.year)
def get_period_key(plan_type):
today = date.today()
if plan_type == "weekly":
return default_weekly_period(today)
if plan_type == "monthly":
return default_monthly_period(today)
if plan_type == "yearly":
return default_yearly_period(today)
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("/periods")
def periods(type: str, user=Depends(get_current_user)):
"""
返回指定计划类型下的全部自然周期列表(从 2022 年到当前,倒序)。
用于前端做周期切换选择器。
"""
if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
today = date.today()
period_list = generate_period_list(type)
current = get_period_key(type)
resp = {
"type": type,
"periods": period_list,
"current_period": current,
"period_labels": {},
}
if type == "weekly":
# 「本周 / 下周」:周五~周日填写的是下一周期
resp["current_week"] = iso_week_key(today)
resp["period_labels"] = {
iso_week_key(today): "本周",
iso_week_key(today + timedelta(days=7)): "下周",
}
elif type == "monthly":
# 「本月 / 下月」:上月末 5 天起填写的是下一周期
next_year, next_month = divmod(today.year * 12 + today.month, 12)
resp["period_labels"] = {
"%04d-%02d" % (today.year, today.month): "本月",
"%04d-%02d" % (next_year, next_month + 1): "下月",
}
elif type == "yearly":
# 「今年 / 明年」:上年 12 月起填写的是下一周期
resp["period_labels"] = {str(today.year): "今年", str(today.year + 1): "明年"}
return resp
@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="无效的计划类型")
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, selected),
)
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}
def _is_teacher(user):
"""判断用户是否为老师角色。"""
return (user.get("role") or "").strip().lower() == "teacher"
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
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, "
"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_sql + " "
"GROUP BY u.`group` ORDER BY u.`group`",
tuple(params),
)
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 = _normalize_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", "")
is_teacher = _is_teacher(user)
my_group = (user.get("group") or "").strip()
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,
"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,
"is_teacher": is_teacher,
"my_group": my_group,
"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 = _normalize_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)
elif plan_type == "monthly":
first_day = parse_month_key(period_key)
if first_day is None:
raise HTTPException(status_code=400, detail="无效的月周期")
# 归一化为规范格式,避免 2026-9 / 2026-09 在同一逻辑月产生两行
period_key = "%04d-%02d" % (first_day.year, first_day.month)
if period_key != default_monthly_period(date.today()):
raise HTTPException(status_code=400, detail="仅可填写当前周期")
can_submit, message = monthly_submit_state(period_key)
if not can_submit:
raise HTTPException(status_code=400, detail=message)
elif plan_type == "yearly":
if not _validate_year_key(period_key):
raise HTTPException(status_code=400, detail="无效的年周期")
if period_key != default_yearly_period(date.today()):
raise HTTPException(status_code=400, detail="仅可填写当前周期")
can_submit, message = yearly_submit_state(period_key)
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}