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}