feat(init): 添加完整的认证和业务路由模块

- 实现微信登录、手机号绑定、token验证和登出功能
- 添加打卡系统,支持正常、迟到、请假等状态记录
- 集成课程投票功能,支持多课程选择和评分评论
- 开发反馈系统,支持图文反馈提交和管理员处理
- 构建首页页面模块,动态展示学习资源和个人信息
- 设计计划管理系统,支持周/月/年计划制定和跟踪
- 实现小组计划统计和历史记录查询功能
- 完善用户认证会话管理和数据库交互逻辑
This commit is contained in:
王宏豪
2026-09-03 16:43:11 +08:00
commit f3fbd0377a
24 changed files with 1918 additions and 0 deletions

17
.env Normal file
View File

@@ -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

54
README.md Normal file
View File

@@ -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 <JWT>`,不使用 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
```

1
__init__.py Normal file
View File

@@ -0,0 +1 @@
"""FastAPI service."""

40
config.py Normal file
View File

@@ -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("/")

97
db.py Normal file
View File

@@ -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)

2
fastapi.log Normal file
View File

@@ -0,0 +1,2 @@
nohup: 忽略输入
nohup: 无法运行命令"./venv311/bin/uvicorn": 没有那个文件或目录

29
main.py Normal file
View File

@@ -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)

8
requirements.txt Normal file
View File

@@ -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

1
routes/__init__.py Normal file
View File

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

109
routes/auth.py Normal file
View File

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

171
routes/checkin.py Normal file
View File

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

86
routes/course_vote.py Normal file
View File

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

109
routes/feedback.py Normal file
View File

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

87
routes/pages.py Normal file
View File

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

474
routes/plan.py Normal file
View File

@@ -0,0 +1,474 @@
import json
import logging
import os
from datetime import date, datetime, time, timedelta
from typing import List
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from .. import config, db
from .auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/plan", tags=["plan"])
PLAN_TYPES = {"weekly", "monthly", "yearly"}
PLAN_STATUSES = {"已完成", "未完成"}
DEFAULT_STATUS = "未完成"
MAX_CONTENT_LEN = 1000
MAX_FILE_SIZE = 10 * 1024 * 1024
MAX_FILES = 4
ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
ATTACH_DIR = config.PLAN_ATTACH_DIR
SELECT_COLS = "id,user_id,plan_type,period_key,status,checked_at,content,attachments,created_at,updated_at"
CONTENT_TYPES = {
"pdf": "application/pdf", "png": "image/png", "jpg": "image/jpeg",
"jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp",
"bmp": "image/bmp", "doc": "application/msword",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
def iso_week_key(day):
iso = day.isocalendar()
return "%d-W%02d" % (iso[0], iso[1])
def parse_iso_week(period_key):
parts = period_key.split("-W")
if len(parts) != 2:
return None
try:
year = int(parts[0])
week = int(parts[1])
except ValueError as exc:
logger.warning("解析 ISO 周周期失败: %s", exc)
return None
jan4 = date(year, 1, 4)
monday = jan4 - timedelta(days=jan4.isoweekday() - 1)
return monday + timedelta(weeks=week - 1)
def default_weekly_period(today=None):
today = today or date.today()
if today.weekday() >= 1:
return iso_week_key(today + timedelta(days=7))
return iso_week_key(today)
def weekly_window(period_key):
monday = parse_iso_week(period_key)
if not monday:
return None, None
open_at = datetime.combine(monday - timedelta(days=3), time.min)
deadline = datetime.combine(monday + timedelta(days=1), time(23, 59, 59))
return open_at, deadline
def weekly_submit_state(period_key):
open_at, deadline = weekly_window(period_key)
if not open_at or not deadline:
return False, "无效的周周期"
now = datetime.now()
if now < open_at:
return False, "周五开始填写"
if now > deadline:
return False, "已过截止时间"
return True, ""
def get_period_key(plan_type):
today = date.today()
if plan_type == "weekly":
return default_weekly_period(today)
if plan_type == "monthly":
return today.strftime("%Y-%m")
if plan_type == "yearly":
return today.strftime("%Y")
return ""
def fmt_dt(value):
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return str(value) if value else ""
def _parse_attachments(raw):
if isinstance(raw, str):
try:
return json.loads(raw)
except Exception:
logger.exception("计划附件字段解析失败")
return []
return raw or []
def fmt_rec(rec):
return {
"id": rec["id"],
"user_id": rec["user_id"],
"plan_type": rec["plan_type"],
"period_key": rec["period_key"],
"status": rec.get("status", DEFAULT_STATUS),
"checked_at": fmt_dt(rec.get("checked_at")),
"content": rec.get("content", ""),
"attachments": _parse_attachments(rec.get("attachments")),
"created_at": fmt_dt(rec.get("created_at")),
"updated_at": fmt_dt(rec.get("updated_at")),
}
class PlanCheckinRequest(BaseModel):
plan_type: str = ""
period_key: str = ""
status: str = DEFAULT_STATUS
content: str = ""
attachments: List[str] = []
@router.get("/status")
def status(type: str, period_key: str = "", user=Depends(get_current_user)):
if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
period_key = period_key.strip() or get_period_key(type)
if type == "weekly":
if parse_iso_week(period_key) is None:
raise HTTPException(status_code=400, detail="无效的周周期")
if period_key != default_weekly_period(date.today()):
raise HTTPException(status_code=400, detail="仅可查询当前周期")
rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM plan_checkins WHERE user_id=%s AND plan_type=%s AND period_key=%s",
(user["id"], type, period_key),
)
can_submit, message = (True, "")
if type == "weekly":
can_submit, message = weekly_submit_state(period_key)
return {"plan_type": type, "period_key": period_key, "checked": rec is not None,
"can_submit": can_submit, "message": message,
"record": fmt_rec(rec) if rec else None}
@router.get("/weekly-groups")
def weekly_groups(period_key: str = "", user=Depends(get_current_user)):
selected, _can_submit, _message = _validate_current_period("weekly", period_key)
stats = _groups_stats("weekly", selected)
groups = [
{
"group_name": g["group_name"],
"checked_count": g["checked_count"],
"unchecked_count": g["unchecked_count"],
"total": g["total"],
}
for g in stats
]
return {"period_key": selected, "groups": groups}
@router.get("/weekly-group")
def weekly_group(group_name: str = "", period_key: str = "", user=Depends(get_current_user)):
default_period = default_weekly_period(date.today())
selected = period_key.strip() or default_period
if selected != default_period:
raise HTTPException(status_code=400, detail="仅可查看当前周期")
group_name = group_name.strip()
if not group_name:
raise HTTPException(status_code=400, detail="缺少小组名")
group_row = db.fetch_one("SELECT `group` FROM auth_users WHERE `group`=%s LIMIT 1", (group_name,))
if not group_row:
raise HTTPException(status_code=404, detail="小组不存在")
can_submit, message = weekly_submit_state(default_period)
members = db.fetch_all(
"SELECT id AS user_id, name FROM auth_users WHERE `group`=%s ORDER BY id",
(group_name,),
)
uids = [m["user_id"] for m in members]
rec_map = {}
if uids:
placeholders = ",".join(["%s"] * len(uids))
records = db.fetch_all(
"SELECT user_id, status, checked_at FROM plan_checkins "
"WHERE plan_type='weekly' AND period_key=%s AND user_id IN (" + placeholders + ")",
tuple([selected] + uids),
)
rec_map = {r["user_id"]: r for r in records}
result = []
for m in members:
rec = rec_map.get(m["user_id"])
result.append({
"user_id": m["user_id"],
"name": m["name"],
"filled": rec is not None,
"record": {
"status": rec["status"],
"checked_at": fmt_dt(rec.get("checked_at")),
} if rec else None,
})
result.sort(key=lambda item: (0 if not item["filled"] else 1, item["user_id"]))
return {
"period_key": selected,
"group_name": group_name,
"can_submit": can_submit,
"message": message,
"members": result,
}
def _validate_current_period(plan_type, period_key):
"""Normalize to the current period for the given plan type.
Returns (selected, can_submit, message); raises 400 for invalid types/periods.
"""
selected = period_key.strip() or get_period_key(plan_type)
if plan_type == "weekly":
if parse_iso_week(selected) is None:
raise HTTPException(status_code=400, detail="无效的周周期")
if selected != default_weekly_period(date.today()):
raise HTTPException(status_code=400, detail="仅可查看当前周期")
can_submit, message = weekly_submit_state(selected)
else:
if selected != get_period_key(plan_type):
raise HTTPException(status_code=400, detail="仅可查看当前周期")
can_submit, message = (True, "")
return selected, can_submit, message
def _groups_stats(plan_type, selected):
"""单条 JOIN 聚合 SQL:提交计划即已完成,未提交即未完成。
按小组统计总人数 / 已完成(有记录)/ 未完成(无记录),避免 N+1 查询。
"""
rows = db.fetch_all(
"SELECT u.`group` AS group_name, "
"COUNT(u.id) AS total, "
"SUM(CASE WHEN pc.user_id IS NOT NULL THEN 1 ELSE 0 END) AS checked_count "
"FROM auth_users u "
"LEFT JOIN plan_checkins pc ON pc.user_id = u.id "
"AND pc.plan_type = %s AND pc.period_key = %s "
"WHERE u.`group` IS NOT NULL AND u.`group` <> '' "
"GROUP BY u.`group` ORDER BY u.`group`",
(plan_type, selected),
)
groups_list = []
for item in rows or []:
total = item.get("total") or 0
checked = item.get("checked_count") or 0
groups_list.append({
"group_name": item.get("group_name") or "",
"total": total,
"checked_count": checked,
"unchecked_count": total - checked,
})
return groups_list
@router.get("/groups")
def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
selected, can_submit, message = _validate_current_period(type, period_key)
my_rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM plan_checkins "
"WHERE user_id=%s AND plan_type=%s AND period_key=%s",
(user["id"], type, selected),
)
my_checked = my_rec is not None
my_status = (my_rec or {}).get("status", "")
groups_list = _groups_stats(type, selected)
my_group = (user.get("group") or "").strip()
if my_group:
mine = [g for g in groups_list if g["group_name"] == my_group]
others = [g for g in groups_list if g["group_name"] != my_group]
groups_list = mine + others
unassigned_row = db.fetch_one(
"SELECT COUNT(*) AS cnt FROM auth_users WHERE `group` IS NULL OR `group` = ''"
)
unassigned_count = unassigned_row["cnt"] if unassigned_row else 0
return {
"type": type,
"period_key": selected,
"can_submit": can_submit,
"message": message,
"my_checked": my_checked,
"my_status": my_status,
"my_record": fmt_rec(my_rec) if my_rec else None,
"unassigned_count": unassigned_count,
"groups": groups_list,
}
@router.get("/group")
def group(type: str, group_name: str, period_key: str = "", user=Depends(get_current_user)):
if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
selected, can_submit, message = _validate_current_period(type, period_key)
group_name = group_name.strip()
if not group_name:
raise HTTPException(status_code=400, detail="缺少小组名")
group_row = db.fetch_one("SELECT `group` FROM auth_users WHERE `group`=%s LIMIT 1", (group_name,))
if not group_row:
raise HTTPException(status_code=404, detail="小组不存在")
members = db.fetch_all(
"SELECT id AS user_id, name FROM auth_users WHERE `group`=%s ORDER BY id",
(group_name,),
)
uids = [m["user_id"] for m in members]
rec_map = {}
if uids:
placeholders = ",".join(["%s"] * len(uids))
records = db.fetch_all(
"SELECT user_id, status, checked_at FROM plan_checkins "
"WHERE plan_type=%s AND period_key=%s AND user_id IN (" + placeholders + ")",
tuple([type, selected] + uids),
)
rec_map = {r["user_id"]: r for r in records}
result = []
for m in members:
rec = rec_map.get(m["user_id"])
result.append({
"user_id": m["user_id"],
"name": m["name"],
"filled": rec is not None,
"record": {
"status": rec["status"],
"checked_at": fmt_dt(rec.get("checked_at")),
} if rec else None,
})
result.sort(key=lambda item: (0 if not item["filled"] else 1, item["user_id"]))
return {
"type": type,
"period_key": selected,
"group_name": group_name,
"can_submit": can_submit,
"message": message,
"members": result,
}
@router.post("/checkin")
def checkin(req: PlanCheckinRequest, user=Depends(get_current_user)):
plan_type = req.plan_type.strip()
if plan_type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
period_key = req.period_key.strip() or get_period_key(plan_type)
if plan_type == "weekly":
if parse_iso_week(period_key) is None:
raise HTTPException(status_code=400, detail="无效的周周期")
default_period = default_weekly_period(date.today())
if period_key != default_period:
raise HTTPException(status_code=400, detail="仅可填写当前周期")
can_submit, message = weekly_submit_state(default_period)
if not can_submit:
raise HTTPException(status_code=400, detail=message)
status = req.status.strip()
if status not in PLAN_STATUSES:
status = DEFAULT_STATUS
content = req.content.strip()
if len(content) > MAX_CONTENT_LEN:
content = content[:MAX_CONTENT_LEN]
attachments = [a for a in req.attachments if isinstance(a, str) and a.strip()][:MAX_FILES]
attachments_json = json.dumps(attachments, ensure_ascii=False)
now = datetime.now()
db.execute(
"INSERT INTO plan_checkins (user_id, plan_type, period_key, status, checked_at, content, attachments) "
"VALUES (%s, %s, %s, %s, %s, %s, %s) "
"ON DUPLICATE KEY UPDATE status=VALUES(status), checked_at=VALUES(checked_at), "
"content=VALUES(content), attachments=VALUES(attachments)",
(user["id"], plan_type, period_key, status, now, content, attachments_json),
)
rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM plan_checkins WHERE user_id=%s AND plan_type=%s AND period_key=%s",
(user["id"], plan_type, period_key),
)
return {"success": True, "record": fmt_rec(rec)}
@router.get("/history")
def history(type: str, page: int = 1, size: int = 10, user=Depends(get_current_user)):
if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
page = max(1, page)
size = max(1, min(50, size))
offset = (page - 1) * size
total_row = db.fetch_one(
"SELECT COUNT(*) AS cnt FROM plan_checkins WHERE user_id=%s AND plan_type=%s",
(user["id"], type),
)
total = total_row["cnt"] if total_row else 0
rows = db.fetch_all(
"SELECT " + SELECT_COLS + " FROM plan_checkins WHERE user_id=%s AND plan_type=%s "
"ORDER BY period_key DESC LIMIT %s OFFSET %s",
(user["id"], type, size, offset),
)
return {"list": [fmt_rec(r) for r in rows], "total": total, "page": page, "size": size}
@router.post("/upload")
def upload(
plan_type: str = Form(...),
period_key: str = Form(""),
files: List[UploadFile] = File(...),
user=Depends(get_current_user),
):
plan_type = plan_type.strip()
if plan_type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
if len(files) > MAX_FILES:
raise HTTPException(status_code=400, detail="最多上传4个文件")
period_key = period_key.strip() or get_period_key(plan_type)
save_dir = os.path.join(ATTACH_DIR, str(user["id"]), plan_type, period_key)
os.makedirs(save_dir, exist_ok=True)
saved = []
for f in files:
data = f.file.read()
if len(data) > MAX_FILE_SIZE:
continue
safe_name = os.path.basename(f.filename or "")
if not safe_name:
continue
ext = os.path.splitext(safe_name)[1].lower()
if ext not in ALLOWED_IMAGE_EXT:
continue
save_path = os.path.join(save_dir, safe_name)
with open(save_path, "wb") as fh:
fh.write(data)
rel_path = "attachments/" + str(user["id"]) + "/" + plan_type + "/" + period_key + "/" + safe_name
saved.append(rel_path)
if not saved:
raise HTTPException(status_code=400, detail="文件保存失败")
return {"success": True, "attachments": saved}
@router.get("/file/{sid}/{plan_type}/{period}/{filename}")
def get_file(sid: str, plan_type: str, period: str, filename: str):
safe_name = os.path.basename(filename)
file_path = os.path.join(ATTACH_DIR, sid, plan_type, period, safe_name)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="not found")
ext = os.path.splitext(safe_name)[1].lstrip(".").lower()
media_type = CONTENT_TYPES.get(ext, "application/octet-stream")
return FileResponse(file_path, media_type=media_type)
@router.delete("/file/{sid}/{plan_type}/{period}/{filename}")
def delete_file(sid: str, plan_type: str, period: str, filename: str, user=Depends(get_current_user)):
if str(sid) != str(user["id"]):
raise HTTPException(status_code=403, detail="无权限")
safe_name = os.path.basename(filename)
file_path = os.path.join(ATTACH_DIR, sid, plan_type, period, safe_name)
if os.path.exists(file_path):
os.remove(file_path)
return {"success": True}

83
routes/profile.py Normal file
View File

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

81
routes/survey.py Normal file
View File

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

132
routes/yijing.py Normal file
View File

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

32
security.py Normal file
View File

@@ -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]

37
start.sh Normal file
View File

@@ -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

42
stop.sh Normal file
View File

@@ -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 "完成"

47
user.py Normal file
View File

@@ -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")),
}

124
uvicorn.log Normal file
View File

@@ -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

55
wechat.py Normal file
View File

@@ -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")