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

1
routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Routes."""

109
routes/auth.py Normal file
View File

@@ -0,0 +1,109 @@
import logging
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from .. import config, db, security, wechat
from ..user import user_payload
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
class WeChatLoginRequest(BaseModel):
code: str
class BindPhoneRequest(BaseModel):
code: str
phone_code: str
def get_current_user(request: Request):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="缺少 token")
token = auth[7:]
session = db.fetch_one(
"SELECT s.user_id AS id FROM auth_sessions s "
"WHERE s.token=%s AND s.expires_at>NOW()",
(token,),
)
if not session:
raise HTTPException(status_code=401, detail="token 无效或过期")
user = db.fetch_one("SELECT * FROM auth_users WHERE id=%s", (session["id"],))
if not user:
raise HTTPException(status_code=401, detail="用户不存在")
return user
def _record_session(user_id):
"""创建会话 token 并写入 auth_sessions。"""
token = uuid.uuid4().hex + uuid.uuid4().hex[:16]
expires = datetime.now() + timedelta(seconds=config.JWT_TTL_SECONDS)
db.execute(
"INSERT INTO auth_sessions (user_id, token, expires_at) VALUES (%s, %s, %s)",
(user_id, token, expires),
)
return token
@router.post("/wechat-login")
def wechat_login(req: WeChatLoginRequest):
try:
openid = wechat.code_to_openid(req.code)
except wechat.WeChatError as exc:
logger.warning("微信登录 code 换取 openid 失败: %s", exc)
raise HTTPException(status_code=400, detail=str(exc))
user = db.fetch_one("SELECT * FROM auth_users WHERE wechat_openid=%s", (openid,))
if user:
token = _record_session(user["id"])
return {"success": True, "is_new": False, "token": token, **user_payload(user)}
return {"success": False, "need_phone": True, "error": "需要绑定手机号"}
@router.post("/bind-phone")
def bind_phone(req: BindPhoneRequest):
try:
openid = wechat.code_to_openid(req.code)
phone = wechat.get_phone_number(req.phone_code)
except wechat.WeChatError as exc:
logger.warning("绑定手机号时获取微信信息失败: %s", exc)
raise HTTPException(status_code=400, detail=str(exc))
if not phone:
raise HTTPException(status_code=400, detail="获取手机号失败")
encrypted = security.encrypt_phone(phone)
user = db.fetch_one("SELECT * FROM auth_users WHERE phone_encrypted=%s", (encrypted,))
if not user:
return {"success": False, "error": "手机号未匹配,无法登录"}
db.execute("UPDATE auth_users SET wechat_openid=%s WHERE id=%s", (openid, user["id"]))
user["wechat_openid"] = openid
token = _record_session(user["id"])
return {"success": True, "is_new": True, "token": token, **user_payload(user)}
@router.get("/verify")
def verify(user=Depends(get_current_user)):
return {"success": True, **user_payload(user)}
@router.post("/logout")
def logout(request: Request, user=Depends(get_current_user)):
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
db.execute("DELETE FROM auth_sessions WHERE token=%s", (auth[7:],))
# 退出登录时解除该用户手机号绑定的微信 openid,下次需重新授权手机号绑定
db.execute(
"UPDATE auth_users SET wechat_openid=NULL WHERE id=%s",
(user["id"],),
)
return {"success": True}

171
routes/checkin.py Normal file
View File

@@ -0,0 +1,171 @@
import logging
import re
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .. import db
from .auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/checkin", tags=["checkin"])
CHECKIN_STATUSES = {"正常", "迟到", "请假", "不参加讨论", "早退"}
DEFAULT_STATUS = "正常"
DEFAULT_COURSE_TYPE = "会员课程"
MAX_NOTE_LEN = 500
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
SELECT_COLS = "id,user_id,class_date,checked_at,status,course_type,note"
def get_class_date():
today = date.today()
return today + timedelta(days=(6 - today.weekday()))
def _is_valid_date(value):
if not isinstance(value, str) or not DATE_RE.match(value):
return False
try:
return datetime.strptime(value, "%Y-%m-%d").date().isoformat() == value
except ValueError as exc:
logger.warning("日期校验失败: %s", exc)
return False
def fmt_dt(value):
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return str(value) if value else ""
def fmt_rec(rec, name=""):
return {
"id": rec["id"],
"user_id": rec["user_id"],
"name": name,
"class_date": str(rec["class_date"]),
"checked_at": fmt_dt(rec.get("checked_at")),
"status": rec.get("status", DEFAULT_STATUS),
"course_type": rec.get("course_type", DEFAULT_COURSE_TYPE),
"note": rec.get("note", ""),
}
class CheckinRequest(BaseModel):
status: str = DEFAULT_STATUS
class_date: str = ""
note: str = ""
course_type: str = DEFAULT_COURSE_TYPE
@router.get("/week")
def week(user=Depends(get_current_user)):
class_date = str(get_class_date())
rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
(user["id"], class_date),
)
return {"class_date": class_date, "checked": rec is not None, "record": fmt_rec(rec) if rec else None}
@router.post("")
def checkin(req: CheckinRequest, user=Depends(get_current_user)):
status = req.status.strip()
if status not in CHECKIN_STATUSES:
raise HTTPException(status_code=400, detail="无效的打卡状态")
class_date = req.class_date.strip() or str(get_class_date())
if not _is_valid_date(class_date):
raise HTTPException(status_code=400, detail="无效的上课日期")
note = req.note.strip() if status != DEFAULT_STATUS else ""
if len(note) > MAX_NOTE_LEN:
raise HTTPException(status_code=400, detail="备注过长")
now = datetime.now()
# 根据 user_id 判断当天是否已打卡:已打卡 → 更新;未打卡 → 新增
existing = db.fetch_one(
"SELECT id FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
(user["id"], class_date),
)
if existing:
db.execute(
"UPDATE checkin_records_old SET checked_at=%s, status=%s, course_type=%s, note=%s WHERE id=%s",
(now, status, DEFAULT_COURSE_TYPE, note, existing["id"]),
)
else:
# course_type 固定为会员课程(规范),忽略客户端传入值
db.execute(
"INSERT INTO checkin_records_old (user_id, class_date, checked_at, status, course_type, note) "
"VALUES (%s, %s, %s, %s, %s, %s)",
(user["id"], class_date, now, status, DEFAULT_COURSE_TYPE, note),
)
rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
(user["id"], class_date),
)
return {"success": True, "record": fmt_rec(rec, user.get("name", "")) if rec else None}
@router.get("/list")
def list_records(page: int = 1, size: int = 10, user=Depends(get_current_user)):
page = max(1, page)
size = max(1, min(50, size))
offset = (page - 1) * size
total_row = db.fetch_one(
"SELECT COUNT(*) AS cnt FROM checkin_records_old WHERE user_id=%s", (user["id"],)
)
total = total_row["cnt"] if total_row else 0
rows = db.fetch_all(
"SELECT " + SELECT_COLS + " FROM checkin_records_old "
"WHERE user_id=%s ORDER BY class_date DESC LIMIT %s OFFSET %s",
(user["id"], size, offset),
)
return {"list": [fmt_rec(r, user.get("name", "")) for r in rows], "total": total, "page": page, "size": size}
@router.get("/group")
def group_view(class_date: str = "", user=Depends(get_current_user)):
class_date = class_date.strip() or str(get_class_date())
if not _is_valid_date(class_date):
raise HTTPException(status_code=400, detail="无效的上课日期")
user_row = db.fetch_one("SELECT `group` FROM auth_users WHERE id=%s", (user["id"],))
if not user_row or not user_row.get("group"):
return {"class_date": class_date, "group_name": "", "members": []}
group_name = user_row["group"]
# 单查询:成员 + 同组成员该上课日的打卡记录,LEFT JOIN 一次取回
rows = db.fetch_all(
"SELECT m.id AS user_id, m.student_id, m.name, "
" r.status, r.checked_at, r.note "
"FROM auth_users m "
"LEFT JOIN checkin_records_old r "
" ON r.user_id = m.id AND r.class_date = %s "
"WHERE m.`group` = %s "
"ORDER BY m.student_id",
(class_date, group_name),
)
result_members = []
for row in rows:
rec = None
if row.get("status") is not None:
rec = {
"status": row["status"],
"checked_at": fmt_dt(row.get("checked_at")),
"note": row.get("note", ""),
}
result_members.append({
"user_id": row["user_id"],
"student_id": row["student_id"],
"name": row["name"],
"role": "",
"checked": rec is not None,
"record": rec,
})
return {"class_date": class_date, "group_name": group_name, "members": result_members}

86
routes/course_vote.py Normal file
View File

@@ -0,0 +1,86 @@
import json
import os
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .. import config
from .auth import get_current_user
router = APIRouter(prefix="/api/v1", tags=["course-vote"])
def _load():
if not os.path.exists(config.COURSE_VOTES_FILE):
return {}
with open(config.COURSE_VOTES_FILE, encoding="utf-8") as f:
content = f.read().strip()
return json.loads(content) if content else {}
def _save(data):
os.makedirs(os.path.dirname(config.COURSE_VOTES_FILE), exist_ok=True)
with open(config.COURSE_VOTES_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
class CourseVoteRequest(BaseModel):
votes: list = []
rating: int = 0
comment: str = ""
@router.post("/course-vote")
def submit_vote(req: CourseVoteRequest, user=Depends(get_current_user)):
votes = req.votes
if not votes or len(votes) < 3 or len(votes) > 6:
raise HTTPException(status_code=400, detail="请选择3-6门课程")
data = _load()
data[user["student_id"]] = {
"name": user["name"],
"votes": votes,
"rating": req.rating,
"comment": req.comment.strip()[:500],
"time": str(datetime.now()),
}
_save(data)
return {"success": True, "msg": "投票成功"}
@router.get("/course-votes")
def get_votes(user=Depends(get_current_user)):
data = _load()
totals = {}
reviews = []
total_rating = 0
rating_count = 0
for sid, info in data.items():
for v in info.get("votes", []):
totals[v] = totals.get(v, 0) + 1
r = info.get("rating", 0)
t = info.get("time", "")
if t and " " in t:
t = t.split(" ")[0]
if r > 0:
total_rating += r
rating_count += 1
reviews.append({"name": info.get("name", sid), "rating": r, "comment": info.get("comment", ""), "time": t})
my_votes = []
my_rating = 0
my_comment = ""
if user["student_id"] in data:
my_votes = data[user["student_id"]].get("votes", [])
my_rating = data[user["student_id"]].get("rating", 0)
my_comment = data[user["student_id"]].get("comment", "")
avg_score = round(total_rating / rating_count, 1) if rating_count > 0 else 0
return {
"success": True,
"totals": totals,
"reviews": reviews,
"avgScore": avg_score,
"myVotes": my_votes,
"myRating": my_rating,
"myComment": my_comment,
"totalVoters": len(data),
}

109
routes/feedback.py Normal file
View File

@@ -0,0 +1,109 @@
import base64
import json
import logging
import os
import re
from datetime import datetime
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from .. import config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
def _image_dir():
return os.path.join(config.FEEDBACK_DIR, "images")
def _ensure_dirs():
os.makedirs(config.FEEDBACK_DIR, exist_ok=True)
os.makedirs(_image_dir(), exist_ok=True)
def _load_items():
items = []
for fn in os.listdir(config.FEEDBACK_DIR):
if fn.endswith(".json"):
path = os.path.join(config.FEEDBACK_DIR, fn)
try:
with open(path, encoding="utf-8") as f:
items.append(json.load(f))
except Exception:
logger.exception("读取反馈数据文件失败: %s", path)
return items
@router.get("")
def list_feedback():
items = _load_items()
unsolved = sorted([x for x in items if x.get("status") == "unsolved"], key=lambda x: x["created_at"], reverse=True)
solved = sorted([x for x in items if x.get("status") != "unsolved"], key=lambda x: x["created_at"], reverse=True)
return unsolved + solved
class FeedbackRequest(BaseModel):
name: str = ""
description: str = ""
image: str = ""
@router.post("")
def submit_feedback(req: FeedbackRequest):
_ensure_dirs()
fid = os.urandom(4).hex()
now = datetime.now().strftime("%Y-%m-%d %H:%M")
item = {
"id": fid,
"name": req.name.strip() or "匿名",
"description": req.description.strip(),
"status": "unsolved",
"created_at": now,
}
img = req.image
if img.startswith("data:image/"):
try:
fmt = re.sub(r"[^a-zA-Z0-9]", "", img.split(";")[0].split("/")[1])
with open(os.path.join(_image_dir(), fid + "." + fmt), "wb") as f:
f.write(base64.b64decode(img.split(",")[1]))
item["image"] = "/api/v1/feedback/file/" + fid + "." + fmt
except Exception:
logger.exception("保存反馈图片失败: %s", fid)
with open(os.path.join(config.FEEDBACK_DIR, fid + ".json"), "w", encoding="utf-8") as f:
json.dump(item, f, ensure_ascii=False)
return item
@router.get("/file/{name}")
def get_file(name: str):
safe_name = os.path.basename(name)
path = os.path.join(_image_dir(), safe_name)
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="not found")
ext = os.path.splitext(safe_name)[1].lstrip(".").lower()
media = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp"}.get(ext, "application/octet-stream")
return FileResponse(path, media_type=media)
@router.post("/resolve")
def resolve(id: str = "", token: str = ""):
safe_id = re.sub(r"[^a-zA-Z0-9]", "", id)
if token != config.ADMIN_KEY:
raise HTTPException(status_code=403, detail="权限不足")
if not safe_id:
raise HTTPException(status_code=404, detail="未找到")
path = os.path.join(config.FEEDBACK_DIR, safe_id + ".json")
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="未找到")
with open(path, encoding="utf-8") as f:
item = json.load(f)
item["status"] = "solved"
item["resolved_at"] = datetime.now().strftime("%Y-%m-%d %H:%M")
with open(path, "w", encoding="utf-8") as f:
json.dump(item, f, ensure_ascii=False)
return {"ok": True, "id": safe_id}

87
routes/pages.py Normal file
View File

@@ -0,0 +1,87 @@
from datetime import datetime
from fastapi import APIRouter, Depends
from .. import config
from ..user import user_payload
from .auth import get_current_user
router = APIRouter(prefix="/api/v1/pages", tags=["pages"])
def _greeting():
hour = datetime.now().hour
if hour < 6:
return "夜深了,还在用功"
if hour < 9:
return "早上好,今天又起了一个大早"
if hour < 12:
return "上午好"
if hour < 14:
return "中午好,记得吃饭"
if hour < 18:
return "下午好"
if hour < 21:
return "晚上好"
return "夜深了,早点休息"
def _xinhuo_member(sid):
return sid.startswith("N") or sid.startswith("0")
def _norm_group(group):
g = (group or "").replace("组", "").strip()
if len(g) == 1:
g = "0" + g
return g
@router.get("/home")
def home(user=Depends(get_current_user)):
base = config.H5_BASE_URL
role = user.get("role", "student")
is_teacher = role == "teacher"
sid = user.get("student_id", "")
payload = user_payload(user)
group = payload["group"]
periods = {cg.get("period") for cg in payload["class_groups"]}
def period_url(period, group_name):
if is_teacher:
return base + "/" + period + "/"
g = _norm_group(group_name)
if g.isdigit():
return base + "/" + period + "/" + g + "-组.html"
return base + "/" + period + "/自由小组.html"
modules = [
{"id": "xinhuo", "type": "period", "icon": "🔥", "label": "薪火相传", "desc": "年课 / 终身学习小组",
"url": base + "/xinhuo/" if is_teacher else base + "/xinhuo/index.html",
"visible": is_teacher or _xinhuo_member(sid)},
{"id": "19qi", "type": "period", "icon": "📖", "label": "19期研修", "desc": "小班课 · 第19期",
"url": period_url("19qi", group), "visible": is_teacher or ("19期" in periods)},
{"id": "18qi", "type": "period", "icon": "📖", "label": "18期研修", "desc": "小班课 · 第18期",
"url": period_url("18qi", group), "visible": is_teacher or ("18期" in periods)},
{"id": "20qi", "type": "period", "icon": "📖", "label": "20期小班课", "desc": "小班课 · 第20期",
"url": period_url("20qi", group), "visible": is_teacher or ("20期" in periods)},
{"id": "teacher", "type": "teacher", "icon": "🎓", "label": "老师大班授权", "desc": "年课 / 长期学员入口",
"url": base + "/teacher/" if (is_teacher or _xinhuo_member(sid)) else base + "/xinhuo/upgrade-info.html",
"visible": True},
{"id": "student360", "type": "profile", "icon": "🧬", "label": "学员360", "desc": "薪火相传 · 个人成长档案",
"url": base + "/xinhuo360/index.html" if is_teacher else base + "/student360/pages/" + sid + "-" + payload["name"] + ".html",
"visible": True},
{"id": "course-matrix", "type": "course", "icon": "🧠", "label": "AI量化课程矩阵", "desc": "12门课程 · 选出你最想学的3门",
"url": base + "/course-matrix.html", "visible": True},
{"id": "demo", "type": "demo", "icon": "🧭", "label": "功能演示", "desc": "先看看讨论和量化报告的样子",
"url": base + "/xinhuo/virtual-group.html", "visible": True},
{"id": "yijing", "type": "study", "icon": "☯", "label": "易经学习", "desc": "周易六十四卦 · 经文详解",
"url": base + "/yijing/", "visible": True},
]
return {
"greeting": _greeting(),
"display_name": payload["display_name"],
"encouragement": "日拱一卒,功不唐捐。",
"modules": [m for m in modules if m["visible"]],
}

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}

83
routes/profile.py Normal file
View File

@@ -0,0 +1,83 @@
import os
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel
from .. import config, db
from ..user import user_payload
from .auth import get_current_user
router = APIRouter(prefix="/api/v1/profile", tags=["profile"])
MAX_AVATAR_SIZE = 4 * 1024 * 1024
ALLOWED_AVATAR_EXT = {".png", ".jpg", ".jpeg", ".webp"}
def _profile(user):
payload = user_payload(user)
return {
"name": payload["name"],
"student_id": payload["student_id"],
"display_name": payload["display_name"],
"wechat_nickname": user.get("wechat_nickname", ""),
"phone": user.get("phone_display", "") or "",
"group": payload["group"],
"role": payload["role"],
"class_groups": payload["class_groups"],
"wechat_avatar": user.get("wechat_avatar", ""),
"login_type": user.get("login_type", ""),
}
class ProfileUpdateRequest(BaseModel):
wechat_nickname: str = ""
wechat_avatar: str = ""
@router.get("")
def get_profile(user=Depends(get_current_user)):
return {"success": True, **_profile(user)}
@router.put("")
def update_profile(req: ProfileUpdateRequest, user=Depends(get_current_user)):
db.execute(
"UPDATE auth_users SET wechat_nickname=%s, wechat_avatar=%s WHERE id=%s",
(req.wechat_nickname, req.wechat_avatar, user["id"]),
)
fresh = db.fetch_one("SELECT * FROM auth_users WHERE id=%s", (user["id"],))
return {"success": True, **_profile(fresh or user)}
@router.post("/avatar")
def upload_avatar(file: UploadFile = File(...), user=Depends(get_current_user)):
data = file.file.read()
if len(data) > MAX_AVATAR_SIZE:
raise HTTPException(status_code=400, detail="头像不能超过4MB")
safe_name = os.path.basename(file.filename or "")
ext = os.path.splitext(safe_name)[1].lower()
if ext not in ALLOWED_AVATAR_EXT:
raise HTTPException(status_code=400, detail="不支持的文件类型")
avatar_base = config.AVATAR_DIR
uid = str(user["id"])
save_dir = os.path.join(avatar_base, uid)
os.makedirs(save_dir, exist_ok=True)
for old_name in os.listdir(save_dir):
if old_name.startswith("avatar.") and old_name != "avatar" + ext:
try:
os.remove(os.path.join(save_dir, old_name))
except OSError:
pass
filename = "avatar" + ext
file_path = os.path.join(save_dir, filename)
with open(file_path, "wb") as f:
f.write(data)
url_path = "/clock/static/avatars/" + uid + "/" + filename
db.execute("UPDATE auth_users SET wechat_avatar=%s WHERE id=%s", (url_path, user["id"]))
return {"success": True, "wechat_avatar": url_path}

81
routes/survey.py Normal file
View File

@@ -0,0 +1,81 @@
import json
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import config, db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/survey", tags=["survey"])
def _fmt_row(row):
if "answers" in row and isinstance(row["answers"], str):
try:
row["answers"] = json.loads(row["answers"])
except Exception:
logger.exception("survey answers 解析失败")
row["answers"] = {}
for key in ("created_at", "submitted_at", "updated_at"):
row[key] = str(row.get(key)) if row.get(key) else ""
return row
class SurveyResponseRequest(BaseModel):
task_id: int
group_id: str
answers: dict = {}
class AnswersRequest(BaseModel):
answers: dict = {}
@router.get("/tasks")
def tasks():
rows = db.fetch_all("SELECT * FROM survey_tasks WHERE status='active' ORDER BY task_no", db=config.SURVEY_DB)
return [_fmt_row(r) for r in rows]
@router.get("/responses")
def responses(task_id: int):
rows = db.fetch_all("SELECT * FROM survey_responses WHERE task_id=%s ORDER BY group_id", (task_id,), db=config.SURVEY_DB)
return [_fmt_row(r) for r in rows]
@router.post("/response")
def response(req: SurveyResponseRequest):
if not req.task_id or not req.group_id or not req.answers:
raise HTTPException(status_code=400, detail="missing fields")
answers_json = json.dumps(req.answers, ensure_ascii=False)
existing = db.fetch_one(
"SELECT id FROM survey_responses WHERE task_id=%s AND group_id=%s",
(req.task_id, req.group_id), db=config.SURVEY_DB,
)
if existing:
db.execute("UPDATE survey_responses SET answers=%s WHERE id=%s", (answers_json, existing["id"]), db=config.SURVEY_DB)
rid = existing["id"]
else:
rid = db.execute(
"INSERT INTO survey_responses (task_id,group_id,answers) VALUES (%s,%s,%s)",
(req.task_id, req.group_id, answers_json), db=config.SURVEY_DB,
)
row = db.fetch_one("SELECT * FROM survey_responses WHERE id=%s", (rid,), db=config.SURVEY_DB)
return _fmt_row(row)
@router.put("/response/{rid}")
def update_response(rid: int, req: AnswersRequest):
db.execute("UPDATE survey_responses SET answers=%s WHERE id=%s", (json.dumps(req.answers, ensure_ascii=False), rid), db=config.SURVEY_DB)
row = db.fetch_one("SELECT * FROM survey_responses WHERE id=%s", (rid,), db=config.SURVEY_DB)
if not row:
raise HTTPException(status_code=404, detail="not found")
return _fmt_row(row)
@router.post("/task")
def task():
return {"msg": "功能预留,后续开放"}

132
routes/yijing.py Normal file
View File

@@ -0,0 +1,132 @@
"""Yijing (易经) APIs, ported from the legacy Flask server to the unified FastAPI service.
All protected endpoints use the same Bearer JWT as the rest of the service, and
the user_id is sourced from the authenticated auth_users row (old cookie sessions
are handled on the H5 side by shrinking down to the token-less /api/auth/verify
fallback before reaching these APIs).
"""
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .. import db
from .auth import get_current_user
router = APIRouter(prefix="/api/v1/yijing", tags=["yijing"])
@router.get("/catalog")
def yijing_catalog():
"""Public catalog: trigrams + hexagrams, mirroring the legacy endpoint."""
trigrams = db.fetch_all(
"SELECT id,name,symbol,nature,de,family,direction,xiantian,houtian,binary_code,class_xiang,summary "
"FROM yijing_trigrams ORDER BY xiantian"
)
hexagrams = db.fetch_all(
"SELECT id,name,full_name,symbol,upper_trigram,lower_trigram,binary_code,judgment,gua_explanation,bagong,gong_wuxing "
"FROM yijing_hexagrams ORDER BY id"
)
return {"success": True, "trigrams": trigrams, "hexagrams": hexagrams}
class ProgressUpdateRequest(BaseModel):
stage: int
item_type: str
item_id: int
status: str = "learning"
@router.get("/progress")
def yijing_progress(user=Depends(get_current_user)):
rows = db.fetch_all(
"SELECT stage,item_type,item_id,status,score,attempts,correct "
"FROM yijing_progress WHERE user_id=%s",
(user["id"],),
)
by = {}
for r in rows:
by.setdefault(r["stage"], {})[
f'{r["item_type"]}:{r["item_id"]}'
] = {
"status": r["status"],
"score": r["score"],
"attempts": r["attempts"],
"correct": r["correct"],
}
return {"success": True, "progress": by}
@router.post("/progress")
def yijing_progress_update(req: ProgressUpdateRequest, user=Depends(get_current_user)):
if not (1 <= req.stage <= 5) or not req.item_type or req.item_id <= 0:
raise HTTPException(status_code=400, detail="参数错误")
db.execute(
"INSERT INTO yijing_progress (user_id,stage,item_type,item_id,status) "
"VALUES (%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE status=VALUES(status)",
(user["id"], req.stage, req.item_type, req.item_id, req.status),
)
return {"success": True}
class QuizRequest(BaseModel):
stage: int
item_type: str
item_id: int
correct: bool = False
answer: str = ""
@router.post("/quiz")
def yijing_quiz(req: QuizRequest, user=Depends(get_current_user)):
if not (1 <= req.stage <= 5) or not req.item_type or req.item_id <= 0:
raise HTTPException(status_code=400, detail="参数错误")
correct = 1 if req.correct else 0
answer = req.answer[:500]
db.execute(
"INSERT INTO yijing_attempts (user_id,stage,item_type,item_id,correct,answer) "
"VALUES (%s,%s,%s,%s,%s,%s)",
(user["id"], req.stage, req.item_type, req.item_id, correct, answer),
)
row = db.fetch_one(
"SELECT score,correct FROM yijing_progress "
"WHERE user_id=%s AND stage=%s AND item_type=%s AND item_id=%s",
(user["id"], req.stage, req.item_type, req.item_id),
)
if not row:
db.execute(
"INSERT INTO yijing_progress (user_id,stage,item_type,item_id,status,score,attempts,correct) "
"VALUES (%s,%s,%s,%s,'learning',%s,1,%s)",
(user["id"], req.stage, req.item_type, req.item_id, correct, correct),
)
else:
db.execute(
"UPDATE yijing_progress SET attempts=attempts+1, correct=correct+%s, "
"score=LEAST(correct+%s,2) WHERE user_id=%s AND stage=%s AND item_type=%s AND item_id=%s",
(correct, correct, user["id"], req.stage, req.item_type, req.item_id),
)
row = db.fetch_one(
"SELECT score,correct FROM yijing_progress "
"WHERE user_id=%s AND stage=%s AND item_type=%s AND item_id=%s",
(user["id"], req.stage, req.item_type, req.item_id),
)
mastered = False
if row and row["score"] >= 2:
db.execute(
"UPDATE yijing_progress SET status='mastered' "
"WHERE user_id=%s AND stage=%s AND item_type=%s AND item_id=%s",
(user["id"], req.stage, req.item_type, req.item_id),
)
mastered = True
due = datetime.now() + (timedelta(days=1) if correct else timedelta(hours=1))
db.execute(
"INSERT INTO yijing_reviews (user_id,item_type,item_id,due_at) VALUES (%s,%s,%s,%s) "
"ON DUPLICATE KEY UPDATE due_at=VALUES(due_at)",
(user["id"], req.item_type, req.item_id, due),
)
return {"success": True, "mastered": mastered, "score": row["score"] if row else 0}