commit f3fbd0377a3ac3d6720113fed0d39de33f5adecf Author: 王宏豪 <2443753184@qq.com> Date: Thu Sep 3 16:43:11 2026 +0800 feat(init): 添加完整的认证和业务路由模块 - 实现微信登录、手机号绑定、token验证和登出功能 - 添加打卡系统,支持正常、迟到、请假等状态记录 - 集成课程投票功能,支持多课程选择和评分评论 - 开发反馈系统,支持图文反馈提交和管理员处理 - 构建首页页面模块,动态展示学习资源和个人信息 - 设计计划管理系统,支持周/月/年计划制定和跟踪 - 实现小组计划统计和历史记录查询功能 - 完善用户认证会话管理和数据库交互逻辑 diff --git a/.env b/.env new file mode 100644 index 0000000..219607b --- /dev/null +++ b/.env @@ -0,0 +1,17 @@ +WECHAT_APPID=wx0f576e94afa52e1a +WECHAT_SECRET=c91063c6b923a6ddba326368488b88c2 +JWT_SECRET=6e6b877bb67669a68d2c1f0c1e1192340293595d2443a3469b4a28c0a68be9db +ADMIN_KEY=870b5219532c1a77a405872f555c7f5cdeec23093740d6793f4e988ebcc70bfc +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=MingYuan20251112.!@# +MYSQL_DB=mys_info_v3 +SURVEY_DB=myxhxc +PHONE_HMAC_KEY=MingYuan2026AuthKey +H5_BASE_URL=https://m.muguaclub.cn +FEEDBACK_DIR=/home/introduction/feedback +COURSE_VOTES_FILE=/home/introduction/test/course_votes.json +PLAN_ATTACH_DIR=/home/static/plan +STATIC_ROOT=/home/static +AVATAR_DIR=/home/static/avatars \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b6edca --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# 明缘进阶场 API(单 FastAPI 服务) + +单服务承载 auth / pages / checkin / plan / profile / course-vote / survey / feedback,统一挂载在 `/api/v1` 下。 + +## 部署 + +1. 安装依赖: + + ```bash + pip install -r server/requirements.txt + ``` + +2. 配置环境变量(复制 `server/.env.example` 后填写): + + - `WECHAT_APPID` / `WECHAT_SECRET`:微信小程序凭证 + - `JWT_SECRET`:JWT 签名密钥(>=32 字节随机串) + - `ADMIN_KEY`:反馈解决接口的管理密钥 + - `MYSQL_*`:`mys_info_test` 数据库连接 + - `SURVEY_DB`:问卷库 `myxhxc` + - `PHONE_HMAC_KEY`:手机号 HMAC 密钥,必须与既有 `phone_encrypted` 生成一致(默认 `MingYuan2026AuthKey`) + - `H5_BASE_URL`:H5 模块基础地址(默认 `https://muguaclub.cn`) + - `FEEDBACK_DIR` / `COURSE_VOTES_FILE` / `PLAN_ATTACH_DIR`:文件存储路径 + +3. 执行 wechat_openid 迁移(一次性): + + ```bash + mysql mys_info_test < server/migrations/001_add_wechat_openid_column.sql + ``` + +4. 启动: + + ```bash + uvicorn server.main:app --host 0.0.0.0 --port 8000 + ``` + +5. 健康检查:`GET /api/v1/health` 返回 `{"ok": true}`。 + +## 认证 + +- `POST /api/v1/auth/wechat-login`:`{code}`,`wechat_openid` 命中自动登录;未命中返回 `need_phone=true`。 +- `POST /api/v1/auth/bind-phone`:`{code, phone_code}`,手机号匹配后回填 `wechat_openid` 并登录。 +- 受保护接口使用 `Authorization: Bearer `,不使用 cookie。 + +## 小程序 + +- 目录:`miniprogram/`,四 tab(首页/打卡/计划/我的)。 +- 首页调用 `GET /api/v1/pages/home` 按返回顺序渲染,模块通过 web-view 打开并附带 `?token=`。 +- H5 引入 `h5/shared/auth.js` 后,`fetch` 自动附带 Bearer token。 + +## 测试 + +```bash +python -m pytest tests -q +``` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a13a436 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +"""FastAPI service.""" diff --git a/config.py b/config.py new file mode 100644 index 0000000..6071c1f --- /dev/null +++ b/config.py @@ -0,0 +1,40 @@ +import os +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv(Path(__file__).parent / ".env", override=True) + + +def _env(name, default=""): + return os.environ.get(name, default) + + +WECHAT_APPID = _env("WECHAT_APPID") +WECHAT_SECRET = _env("WECHAT_SECRET") + +JWT_SECRET = _env("JWT_SECRET") +JWT_ALGORITHM = "HS256" +JWT_TTL_SECONDS = int(_env("JWT_TTL_SECONDS", str(90 * 24 * 3600))) + +ADMIN_KEY = _env("ADMIN_KEY") + +MYSQL_HOST = _env("MYSQL_HOST", "localhost") +MYSQL_PORT = int(_env("MYSQL_PORT", "3306")) +MYSQL_USER = _env("MYSQL_USER", "root") +MYSQL_PASSWORD = _env("MYSQL_PASSWORD") +MYSQL_DB = _env("MYSQL_DB", "mys_info_v3") +SURVEY_DB = _env("SURVEY_DB", "myxhxc") + +PHONE_HMAC_KEY = _env("PHONE_HMAC_KEY", "MingYuan2026AuthKey") + +H5_BASE_URL = _env("H5_BASE_URL", "http://127.0.0.1:8001").rstrip("/") + +FEEDBACK_DIR = _env("FEEDBACK_DIR", "/home/introduction/feedback") +COURSE_VOTES_FILE = _env("COURSE_VOTES_FILE", "/home/introduction/test/course_votes.json") +PLAN_ATTACH_DIR = _env("PLAN_ATTACH_DIR", "/home/static/plan") + +# nginx 静态资源根目录:HEAD/登录后图片统一由 nginx 直接读取 +STATIC_ROOT = _env("STATIC_ROOT", "/home/static").rstrip("/") +AVATAR_DIR = _env("AVATAR_DIR", "/home/static/avatars").rstrip("/") + diff --git a/db.py b/db.py new file mode 100644 index 0000000..2ea57e0 --- /dev/null +++ b/db.py @@ -0,0 +1,97 @@ +"""Database access with a PyMySQL connection pool. + +Each fetch/execute borrows a pooled connection and returns it on close(). +Pool settings are read from environment (DB_POOL_*), so nothing here depends +on config.py and the pool can be shared safely across threads via DBUtils. +""" +import os +import logging +import threading + +import pymysql +from dbutils.pooled_db import PooledDB + +from . import config + + +logger = logging.getLogger(__name__) + + +def _pool_args(database): + return dict( + mincached=int(os.environ.get("DB_POOL_MIN_CACHED", "1")), + maxcached=int(os.environ.get("DB_POOL_MAX_CACHED", "5")), + maxconnections=int(os.environ.get("DB_POOL_MAX_CONNECTIONS", "10")), + blocking=True, + maxusage=int(os.environ.get("DB_POOL_MAX_USAGE", "100")), + ping=1, + host=config.MYSQL_HOST, + port=config.MYSQL_PORT, + user=config.MYSQL_USER, + password=config.MYSQL_PASSWORD, + database=database, + charset="utf8mb4", + connect_timeout=5, + read_timeout=5, + ) + + +_pools = {} +_pools_lock = threading.Lock() + + +def _get_pool(database): + key = database or config.MYSQL_DB + with _pools_lock: + pool = _pools.get(key) + if pool is None: + pool = PooledDB(creator=pymysql, **_pool_args(key)) + _pools[key] = pool + return pool + + +def _connect(db=None): + """Borrow a pooled connection (close() returns it to the pool).""" + return _get_pool(db).connection() + + +def fetch_one(sql, args=None, db=None): + conn = _connect(db) + try: + with conn.cursor(pymysql.cursors.DictCursor) as cur: + cur.execute(sql, args or ()) + return cur.fetchone() + finally: + conn.close() + + +def fetch_all(sql, args=None, db=None): + conn = _connect(db) + try: + with conn.cursor(pymysql.cursors.DictCursor) as cur: + cur.execute(sql, args or ()) + return cur.fetchall() + finally: + conn.close() + + +def execute(sql, args=None, db=None): + conn = _connect(db) + try: + with conn.cursor() as cur: + cur.execute(sql, args or ()) + lastrowid = cur.lastrowid + conn.commit() + return lastrowid + finally: + conn.close() + + +def close_all(): + """Close every pooled connection (used by tests / reload hooks).""" + with _pools_lock: + for key in list(_pools): + try: + _pools.pop(key).close() + except Exception: + logger.exception("关闭数据库连接池失败: %s", key) diff --git a/fastapi.log b/fastapi.log new file mode 100644 index 0000000..68c06d4 --- /dev/null +++ b/fastapi.log @@ -0,0 +1,2 @@ +nohup: 忽略输入 +nohup: 无法运行命令"./venv311/bin/uvicorn": 没有那个文件或目录 diff --git a/main.py b/main.py new file mode 100644 index 0000000..0dc8e52 --- /dev/null +++ b/main.py @@ -0,0 +1,29 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from fastapi import FastAPI + +from server_prd.routes import auth, checkin, course_vote, feedback, pages, plan, profile, survey, yijing + +app = FastAPI(title="明缘进阶场 API") +app.include_router(auth.router) +app.include_router(pages.router) +app.include_router(checkin.router) +app.include_router(plan.router) +app.include_router(profile.router) +app.include_router(survey.router) +app.include_router(feedback.router) +app.include_router(course_vote.router) +app.include_router(yijing.router) + + +@app.get("/api/v1/health") +def health(): + return {"ok": True} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("server_prd.main:app", host="127.0.0.1", port=8000, reload=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4a4a85a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.110 +uvicorn[standard]>=0.29 +pymysql>=1.1 +PyJWT>=2.8 +httpx>=0.27 +python-multipart>=0.0.9 +pytest>=8.0 +DBUtils>=3.0 diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..7aeb33c --- /dev/null +++ b/routes/__init__.py @@ -0,0 +1 @@ +"""Routes.""" diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..ff02317 --- /dev/null +++ b/routes/auth.py @@ -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} \ No newline at end of file diff --git a/routes/checkin.py b/routes/checkin.py new file mode 100644 index 0000000..1efac6d --- /dev/null +++ b/routes/checkin.py @@ -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} diff --git a/routes/course_vote.py b/routes/course_vote.py new file mode 100644 index 0000000..dd6ff7b --- /dev/null +++ b/routes/course_vote.py @@ -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), + } \ No newline at end of file diff --git a/routes/feedback.py b/routes/feedback.py new file mode 100644 index 0000000..85a53a6 --- /dev/null +++ b/routes/feedback.py @@ -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} diff --git a/routes/pages.py b/routes/pages.py new file mode 100644 index 0000000..3680e42 --- /dev/null +++ b/routes/pages.py @@ -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"]], + } \ No newline at end of file diff --git a/routes/plan.py b/routes/plan.py new file mode 100644 index 0000000..f74d9ea --- /dev/null +++ b/routes/plan.py @@ -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} diff --git a/routes/profile.py b/routes/profile.py new file mode 100644 index 0000000..f7f8aec --- /dev/null +++ b/routes/profile.py @@ -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} + diff --git a/routes/survey.py b/routes/survey.py new file mode 100644 index 0000000..2a1877b --- /dev/null +++ b/routes/survey.py @@ -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": "功能预留,后续开放"} diff --git a/routes/yijing.py b/routes/yijing.py new file mode 100644 index 0000000..d7b640d --- /dev/null +++ b/routes/yijing.py @@ -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} diff --git a/security.py b/security.py new file mode 100644 index 0000000..b79cb47 --- /dev/null +++ b/security.py @@ -0,0 +1,32 @@ +import logging +import uuid +import hmac +import hashlib +import time + +import jwt as pyjwt + +from . import config + + +def encrypt_phone(phone): + return hmac.new( + config.PHONE_HMAC_KEY.encode("utf-8"), + phone.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +def create_access_token(user_id): + now = int(time.time()) + payload = {"sub": str(user_id), "iat": now, "exp": now + config.JWT_TTL_SECONDS} + return pyjwt.encode(payload, config.JWT_SECRET, algorithm=config.JWT_ALGORITHM) + + +def decode_access_token(token): + return pyjwt.decode(token, config.JWT_SECRET, algorithms=[config.JWT_ALGORITHM]) + + +def create_session_token(): + """生成写入 auth_sessions 的会话 token(长度适合 token VARCHAR(64))。""" + return uuid.uuid4().hex + uuid.uuid4().hex[:16] \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..d105b4c --- /dev/null +++ b/start.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# 明缘进阶场 FastAPI 启动脚本 (CentOS7, 固定路径) +cd /home/clock_server || exit 1 + +PID_FILE=/home/clock_server/logs/prd/server.pid +LOG_DIR=/home/clock_server/logs +OUT_LOG=/home/clock_server/logs/prd/server.out.log +ERR_LOG=/home/clock_server/logs/prd/server.err.log + +mkdir -p "$LOG_DIR" + +if [ -f "$PID_FILE" ]; then + OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)" + if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then + echo "服务已在运行 (PID $OLD_PID)" + exit 1 + fi +fi + +# 清理 Python 字节码缓存,确保加载最新代码 +echo "清理 __pycache__..." +find /home/clock_server/server_prd -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null +find /home/clock_server/server_prd -type f -name "*.pyc" -delete 2>/dev/null +echo "清理完成" + +nohup /home/clock_server/pyenv39/bin/python -m uvicorn server_prd.main:app --host 0.0.0.0 --port 8001 >"$OUT_LOG" 2>"$ERR_LOG" & +echo $! > "$PID_FILE" + +sleep 1 +if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "启动成功 PID $(cat "$PID_FILE") (http://0.0.0.0:8001)" + echo "日志: $OUT_LOG" + echo "错误: $ERR_LOG" +else + echo "启动失败,请查看: $ERR_LOG" >&2 + exit 1 +fi \ No newline at end of file diff --git a/stop.sh b/stop.sh new file mode 100644 index 0000000..60ed700 --- /dev/null +++ b/stop.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# 明缘进阶场 FastAPI 停止脚本 (CentOS7, 固定路径) +PID_FILE=/home/clock_server/logs/prd/server.pid +PORT=8001 + +kill_pid() { + local pid="$1" + [ -z "$pid" ] && return 0 + echo "停止 PID $pid" + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 30); do + if ! kill -0 "$pid" 2>/dev/null; then break; fi + sleep 0.5 + done + if kill -0 "$pid" 2>/dev/null; then + echo "进程未退出,强制结束 PID $pid" + kill -9 "$pid" 2>/dev/null || true + fi +} + +# 1) 按 PID 文件 +if [ -f "$PID_FILE" ]; then + PID="$(cat "$PID_FILE" 2>/dev/null || true)" + kill_pid "$PID" + rm -f "$PID_FILE" +fi + +# 2) 按命令行兜底(uvicorn) +for p in $(pgrep -f "uvicorn server_prd.main:app" 2>/dev/null || true); do + kill_pid "$p" +done + +# 3) 按端口兜底:杀掉占用 8001 的任何进程 +FUSER_BIN="$(command -v fuser || true)" +if [ -n "$FUSER_BIN" ]; then + echo "检查并释放端口 $PORT" + $FUSER_BIN -k "$PORT/tcp" 2>/dev/null || true + $FUSER_BIN -k -n tcp "$PORT" 2>/dev/null || true +fi +sleep 1 + +echo "完成" \ No newline at end of file diff --git a/user.py b/user.py new file mode 100644 index 0000000..53a12a8 --- /dev/null +++ b/user.py @@ -0,0 +1,47 @@ +import re + +from . import db + + +def make_display_name(group, name): + if group and re.match(r"^\d{2}组$", group): + return group + name + return name + + +def get_class_groups(user_id): + return db.fetch_all( + "SELECT c.id AS class_id, c.code AS class_code, c.name AS period, " + "g.group_code AS `group`, cg.role_in_group AS role " + "FROM class_groups cg " + "JOIN classes c ON c.id=cg.class_id " + "JOIN `groups` g ON g.id=cg.group_id " + "WHERE cg.user_id=%s ORDER BY c.id, g.sort", + (user_id,), + ) + + +def get_history_groups(user_id): + return db.fetch_all( + "SELECT og.group_code AS old_group FROM group_history gh " + "JOIN classes c ON c.id=gh.class_id " + "LEFT JOIN `groups` og ON og.id=gh.old_group_id " + "WHERE gh.user_id=%s ORDER BY gh.changed_at DESC", + (user_id,), + ) + + +def user_payload(user): + group = user.get("group") or "" + class_groups = get_class_groups(user["id"]) + history_groups = [h["old_group"] for h in get_history_groups(user["id"])] + return { + "name": user["name"], + "student_id": user["student_id"], + "role": user.get("role", "student"), + "group": group, + "display_name": make_display_name(group, user["name"]), + "class_groups": class_groups, + "history_groups": history_groups, + "first_login": bool(user.get("first_login")), + } \ No newline at end of file diff --git a/uvicorn.log b/uvicorn.log new file mode 100644 index 0000000..c26e53a --- /dev/null +++ b/uvicorn.log @@ -0,0 +1,124 @@ +nohup: 忽略输入 +INFO: Started server process [28729] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +INFO: 127.0.0.1:59276 - "GET /api/v1/plan/groups?type=monthly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59280 - "GET /api/v1/checkin/week HTTP/1.0" 200 OK +INFO: 127.0.0.1:59282 - "GET /api/v1/checkin/group?class_date=2026-08-30 HTTP/1.0" 200 OK +INFO: 127.0.0.1:59300 - "GET /api/v1/plan/groups?type=monthly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59302 - "GET /api/v1/checkin/week HTTP/1.0" 200 OK +INFO: 127.0.0.1:59304 - "GET /api/v1/checkin/group?class_date=2026-08-30 HTTP/1.0" 200 OK +INFO: 127.0.0.1:59306 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:59310 - "POST /api/v1/auth/logout HTTP/1.0" 200 OK +INFO: 127.0.0.1:59312 - "POST /api/v1/auth/wechat-login HTTP/1.0" 200 OK +INFO: 127.0.0.1:59316 - "GET /api/v1/pages/home HTTP/1.0" 401 Unauthorized +INFO: 127.0.0.1:59318 - "POST /api/v1/auth/wechat-login HTTP/1.0" 200 OK +INFO: 127.0.0.1:59324 - "POST /api/v1/auth/bind-phone HTTP/1.0" 200 OK +INFO: 127.0.0.1:59334 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:59336 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59340 - "GET /api/v1/plan/groups?type=monthly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59342 - "GET /api/v1/plan/groups?type=yearly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59344 - "GET /api/v1/plan/groups?type=monthly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59348 - "GET /api/v1/plan/groups?type=yearly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59350 - "GET /api/v1/checkin/week HTTP/1.0" 200 OK +INFO: 127.0.0.1:59352 - "GET /api/v1/checkin/group?class_date=2026-08-30 HTTP/1.0" 200 OK +INFO: 127.0.0.1:59358 - "GET /api/v1/checkin/list?page=1&size=20 HTTP/1.0" 200 OK +INFO: 127.0.0.1:59360 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:59368 - "GET /api/v1/checkin/week HTTP/1.0" 200 OK +INFO: 127.0.0.1:59370 - "GET /api/v1/checkin/group?class_date=2026-08-30 HTTP/1.0" 200 OK +INFO: 127.0.0.1:59372 - "GET /api/v1/plan/groups?type=yearly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59374 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:59376 - "GET /api/v1/plan/groups?type=yearly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59378 - "GET /api/v1/checkin/week HTTP/1.0" 200 OK +INFO: 127.0.0.1:59380 - "GET /api/v1/checkin/group?class_date=2026-08-30 HTTP/1.0" 200 OK +INFO: 127.0.0.1:59384 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:59734 - "POST /api/v1/auth/wechat-login HTTP/1.0" 200 OK +INFO: 127.0.0.1:59736 - "POST /api/v1/auth/wechat-login HTTP/1.0" 200 OK +INFO: 127.0.0.1:59742 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:59746 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59752 - "GET /api/v1/plan/groups?type=monthly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59756 - "GET /api/v1/plan/groups?type=yearly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59758 - "GET /api/v1/plan/groups?type=monthly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59760 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:59764 - "GET /api/v1/checkin/week HTTP/1.0" 200 OK +INFO: 127.0.0.1:59766 - "GET /api/v1/checkin/group?class_date=2026-08-30 HTTP/1.0" 200 OK +INFO: 127.0.0.1:59768 - "POST /api/v1/checkin HTTP/1.0" 200 OK +INFO: 127.0.0.1:59770 - "GET /api/v1/checkin/group?class_date=2026-08-30 HTTP/1.0" 200 OK +INFO: 127.0.0.1:59774 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:59778 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:60964 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:32786 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:32788 - "GET /api/v1/checkin/week HTTP/1.0" 200 OK +INFO: 127.0.0.1:32790 - "GET /api/v1/checkin/group?class_date=2026-08-30 HTTP/1.0" 200 OK +INFO: 127.0.0.1:32794 - "GET /api/v1/checkin/list?page=1&size=20 HTTP/1.0" 200 OK +INFO: 127.0.0.1:32796 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:32800 - "GET /api/v1/plan/group?type=weekly&group_name=05%E7%BB%84&period_key=2026-W36 HTTP/1.0" 200 OK +INFO: 127.0.0.1:32804 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:32806 - "GET /api/v1/plan/groups?type=monthly HTTP/1.0" 200 OK +INFO: 127.0.0.1:32808 - "GET /api/v1/plan/groups?type=yearly HTTP/1.0" 200 OK +INFO: 127.0.0.1:32810 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:32814 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:32824 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:32828 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:32832 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:32838 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:38612 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:38624 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:38636 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:38642 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:38648 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:38674 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:38676 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:38696 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:38900 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:38908 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:38910 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:40012 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:40036 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:40072 - "GET /api/v1/health HTTP/1.0" 200 OK +INFO: 127.0.0.1:40078 - "GET /api/v1/pages/home HTTP/1.0" 401 Unauthorized +INFO: 127.0.0.1:40120 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:40124 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:40132 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:40134 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:40144 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:40146 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:40162 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:40172 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:40184 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:40332 - "GET /api/v1/checkin/week HTTP/1.0" 200 OK +INFO: 127.0.0.1:40334 - "GET /api/v1/checkin/group?class_date=2026-08-30 HTTP/1.0" 200 OK +INFO: 127.0.0.1:40336 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:40344 - "GET /api/v1/plan/groups?type=monthly HTTP/1.0" 200 OK +INFO: 127.0.0.1:40346 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:40348 - "GET /api/v1/plan/status?type=weekly&period_key=2026-W36 HTTP/1.0" 200 OK +INFO: 127.0.0.1:40352 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:40888 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:55926 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:55940 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:55946 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:55954 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:55962 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:55966 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:55978 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:55988 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:55992 - "GET /api/v1/checkin/week HTTP/1.0" 200 OK +INFO: 127.0.0.1:55994 - "GET /api/v1/checkin/group?class_date=2026-08-30 HTTP/1.0" 200 OK +INFO: 127.0.0.1:55996 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:55998 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:56000 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:56004 - "GET /api/v1/plan/group?type=weekly&group_name=04%E7%BB%84&period_key=2026-W36 HTTP/1.0" 200 OK +INFO: 127.0.0.1:56494 - "POST /api/v1/auth/wechat-login HTTP/1.0" 200 OK +INFO: 127.0.0.1:56492 - "POST /api/v1/auth/wechat-login HTTP/1.0" 200 OK +INFO: 127.0.0.1:56500 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:56502 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:56504 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:56506 - "GET /api/v1/checkin/week HTTP/1.0" 200 OK +INFO: 127.0.0.1:56508 - "GET /api/v1/checkin/group?class_date=2026-08-30 HTTP/1.0" 200 OK +INFO: 127.0.0.1:56510 - "GET /api/v1/profile HTTP/1.0" 200 OK +INFO: 127.0.0.1:41814 - "GET /api/v1/pages/home HTTP/1.0" 200 OK +INFO: 127.0.0.1:41818 - "GET /api/v1/checkin/week HTTP/1.0" 200 OK +INFO: 127.0.0.1:41820 - "GET /api/v1/checkin/group?class_date=2026-08-30 HTTP/1.0" 200 OK +INFO: 127.0.0.1:41822 - "GET /api/v1/plan/groups?type=weekly HTTP/1.0" 200 OK +INFO: 127.0.0.1:41824 - "GET /api/v1/profile HTTP/1.0" 200 OK diff --git a/wechat.py b/wechat.py new file mode 100644 index 0000000..8d74d47 --- /dev/null +++ b/wechat.py @@ -0,0 +1,55 @@ +import httpx + +from . import config + + +class WeChatError(Exception): + pass + + +def code_to_openid(code): + resp = httpx.get( + "https://api.weixin.qq.com/sns/jscode2session", + params={ + "appid": config.WECHAT_APPID, + "secret": config.WECHAT_SECRET, + "js_code": code, + "grant_type": "authorization_code", + }, + timeout=10, + ) + data = resp.json() + if data.get("errcode") or "openid" not in data: + raise WeChatError(data.get("errmsg", "无效的登录 code")) + return data["openid"] + + +def _get_access_token(): + resp = httpx.get( + "https://api.weixin.qq.com/cgi-bin/token", + params={ + "grant_type": "client_credential", + "appid": config.WECHAT_APPID, + "secret": config.WECHAT_SECRET, + }, + timeout=10, + ) + data = resp.json() + if not data.get("access_token"): + raise WeChatError(data.get("errmsg", "获取微信 access_token 失败")) + return data["access_token"] + + +def get_phone_number(phone_code): + access_token = _get_access_token() + resp = httpx.post( + "https://api.weixin.qq.com/wxa/business/getuserphonenumber", + params={"access_token": access_token}, + json={"code": phone_code}, + timeout=10, + ) + data = resp.json() + if data.get("errcode") != 0: + raise WeChatError(data.get("errmsg", "获取手机号失败")) + phone_info = data.get("phone_info") or {} + return phone_info.get("purePhoneNumber") or phone_info.get("phoneNumber")