feat(init): 添加完整的认证和业务路由模块
- 实现微信登录、手机号绑定、token验证和登出功能 - 添加打卡系统,支持正常、迟到、请假等状态记录 - 集成课程投票功能,支持多课程选择和评分评论 - 开发反馈系统,支持图文反馈提交和管理员处理 - 构建首页页面模块,动态展示学习资源和个人信息 - 设计计划管理系统,支持周/月/年计划制定和跟踪 - 实现小组计划统计和历史记录查询功能 - 完善用户认证会话管理和数据库交互逻辑
This commit is contained in:
1
routes/__init__.py
Normal file
1
routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Routes."""
|
||||
109
routes/auth.py
Normal file
109
routes/auth.py
Normal 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
171
routes/checkin.py
Normal 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
86
routes/course_vote.py
Normal 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
109
routes/feedback.py
Normal 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
87
routes/pages.py
Normal 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
474
routes/plan.py
Normal 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
83
routes/profile.py
Normal 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
81
routes/survey.py
Normal 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
132
routes/yijing.py
Normal 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}
|
||||
Reference in New Issue
Block a user