Files
wechat_backend/routes/plan.py
王宏豪 0395b58eaf feat(plan): 添加周计划打卡名单查询功能
- 新增 USER_STATUS_NORMAL 常量用于筛选正常状态学员
- 修改小组成员查询接口,增加 student_id 字段返回
- 扩展打卡记录查询,支持 content 和 attachments 字段
- 在打卡结果中添加 student_id、content 和 attachments 信息
- 新增 /records 接口供教师查询周计划打卡名单
- 名单统计仅包含状态为'正常'的学员
- 实现按组别和学号排序的打卡状态统计功能
2026-09-16 16:23:45 +08:00

743 lines
27 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 = "未完成"
USER_STATUS_NORMAL = "正常" # auth_users.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, student_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, content, attachments 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"],
"student_id": m.get("student_id") or "",
"name": m["name"],
"filled": rec is not None,
"record": {
"status": rec["status"],
"checked_at": fmt_dt(rec.get("checked_at")),
"content": rec.get("content") or "",
"attachments": _parse_attachments(rec.get("attachments")),
} 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.get("/records")
def records(period_key: str = "", user=Depends(get_current_user)):
"""
周计划打卡名单(仅老师可查)。
请求参数:
- period_key 周(ISO 周,如 `2026-W38`);不传取当前应填周(周五~周日为下一周)
返回:`user_id` / `student_id` / `name` / `group_name` / `checked`
判定口径:该周在 `plan_checkins` 中存在记录 = 已打卡,与 `/groups` 的小组统计口径一致。
仅统计 `auth_users.status = '正常'` 的学员(`暂停` 等状态不计入名单)。
学员(非 teacher)一律拒绝。
"""
if not _is_teacher(user):
raise HTTPException(status_code=403, detail="无权限")
selected = period_key.strip() or get_period_key("weekly")
if parse_iso_week(selected) is None:
raise HTTPException(status_code=400, detail="无效的周周期")
# 用户表 LEFT JOIN 周计划打卡记录表,一次取回全员(含未打卡)避免 N+1
rows = db.fetch_all(
"SELECT u.id AS user_id, u.student_id, u.name, u.`group` AS group_name, "
" pc.id AS record_id "
"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.`status` = %s "
"ORDER BY u.`group`, u.student_id",
("weekly", selected, USER_STATUS_NORMAL),
)
return {
"period_key": selected,
"list": [
{
"user_id": r["user_id"],
"student_id": r.get("student_id") or "",
"name": r.get("name") or "",
"group_name": r.get("group_name") or "",
"checked": r.get("record_id") is not None,
}
for r in rows or []
],
}
@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}