feat(init): 添加完整的认证和业务路由模块
- 实现微信登录、手机号绑定、token验证和登出功能 - 添加打卡系统,支持正常、迟到、请假等状态记录 - 集成课程投票功能,支持多课程选择和评分评论 - 开发反馈系统,支持图文反馈提交和管理员处理 - 构建首页页面模块,动态展示学习资源和个人信息 - 设计计划管理系统,支持周/月/年计划制定和跟踪 - 实现小组计划统计和历史记录查询功能 - 完善用户认证会话管理和数据库交互逻辑
This commit is contained in:
17
.env
Normal file
17
.env
Normal 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
54
README.md
Normal 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
1
__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""FastAPI service."""
|
||||
40
config.py
Normal file
40
config.py
Normal 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
97
db.py
Normal 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
2
fastapi.log
Normal file
@@ -0,0 +1,2 @@
|
||||
nohup: 忽略输入
|
||||
nohup: 无法运行命令"./venv311/bin/uvicorn": 没有那个文件或目录
|
||||
29
main.py
Normal file
29
main.py
Normal 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
8
requirements.txt
Normal 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
1
routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Routes."""
|
||||
109
routes/auth.py
Normal file
109
routes/auth.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import config, db, security, wechat
|
||||
from ..user import user_payload
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
class WeChatLoginRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class BindPhoneRequest(BaseModel):
|
||||
code: str
|
||||
phone_code: str
|
||||
|
||||
|
||||
def get_current_user(request: Request):
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="缺少 token")
|
||||
token = auth[7:]
|
||||
session = db.fetch_one(
|
||||
"SELECT s.user_id AS id FROM auth_sessions s "
|
||||
"WHERE s.token=%s AND s.expires_at>NOW()",
|
||||
(token,),
|
||||
)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="token 无效或过期")
|
||||
user = db.fetch_one("SELECT * FROM auth_users WHERE id=%s", (session["id"],))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
def _record_session(user_id):
|
||||
"""创建会话 token 并写入 auth_sessions。"""
|
||||
token = uuid.uuid4().hex + uuid.uuid4().hex[:16]
|
||||
expires = datetime.now() + timedelta(seconds=config.JWT_TTL_SECONDS)
|
||||
db.execute(
|
||||
"INSERT INTO auth_sessions (user_id, token, expires_at) VALUES (%s, %s, %s)",
|
||||
(user_id, token, expires),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/wechat-login")
|
||||
def wechat_login(req: WeChatLoginRequest):
|
||||
try:
|
||||
openid = wechat.code_to_openid(req.code)
|
||||
except wechat.WeChatError as exc:
|
||||
logger.warning("微信登录 code 换取 openid 失败: %s", exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
user = db.fetch_one("SELECT * FROM auth_users WHERE wechat_openid=%s", (openid,))
|
||||
if user:
|
||||
token = _record_session(user["id"])
|
||||
return {"success": True, "is_new": False, "token": token, **user_payload(user)}
|
||||
|
||||
return {"success": False, "need_phone": True, "error": "需要绑定手机号"}
|
||||
|
||||
|
||||
@router.post("/bind-phone")
|
||||
def bind_phone(req: BindPhoneRequest):
|
||||
try:
|
||||
openid = wechat.code_to_openid(req.code)
|
||||
phone = wechat.get_phone_number(req.phone_code)
|
||||
except wechat.WeChatError as exc:
|
||||
logger.warning("绑定手机号时获取微信信息失败: %s", exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
if not phone:
|
||||
raise HTTPException(status_code=400, detail="获取手机号失败")
|
||||
|
||||
encrypted = security.encrypt_phone(phone)
|
||||
user = db.fetch_one("SELECT * FROM auth_users WHERE phone_encrypted=%s", (encrypted,))
|
||||
if not user:
|
||||
return {"success": False, "error": "手机号未匹配,无法登录"}
|
||||
|
||||
db.execute("UPDATE auth_users SET wechat_openid=%s WHERE id=%s", (openid, user["id"]))
|
||||
user["wechat_openid"] = openid
|
||||
token = _record_session(user["id"])
|
||||
return {"success": True, "is_new": True, "token": token, **user_payload(user)}
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
def verify(user=Depends(get_current_user)):
|
||||
return {"success": True, **user_payload(user)}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, user=Depends(get_current_user)):
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
db.execute("DELETE FROM auth_sessions WHERE token=%s", (auth[7:],))
|
||||
# 退出登录时解除该用户手机号绑定的微信 openid,下次需重新授权手机号绑定
|
||||
db.execute(
|
||||
"UPDATE auth_users SET wechat_openid=NULL WHERE id=%s",
|
||||
(user["id"],),
|
||||
)
|
||||
return {"success": True}
|
||||
171
routes/checkin.py
Normal file
171
routes/checkin.py
Normal file
@@ -0,0 +1,171 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import db
|
||||
from .auth import get_current_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/checkin", tags=["checkin"])
|
||||
|
||||
CHECKIN_STATUSES = {"正常", "迟到", "请假", "不参加讨论", "早退"}
|
||||
DEFAULT_STATUS = "正常"
|
||||
DEFAULT_COURSE_TYPE = "会员课程"
|
||||
MAX_NOTE_LEN = 500
|
||||
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
SELECT_COLS = "id,user_id,class_date,checked_at,status,course_type,note"
|
||||
|
||||
|
||||
def get_class_date():
|
||||
today = date.today()
|
||||
return today + timedelta(days=(6 - today.weekday()))
|
||||
|
||||
|
||||
def _is_valid_date(value):
|
||||
if not isinstance(value, str) or not DATE_RE.match(value):
|
||||
return False
|
||||
try:
|
||||
return datetime.strptime(value, "%Y-%m-%d").date().isoformat() == value
|
||||
except ValueError as exc:
|
||||
logger.warning("日期校验失败: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def fmt_dt(value):
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(value) if value else ""
|
||||
|
||||
|
||||
def fmt_rec(rec, name=""):
|
||||
return {
|
||||
"id": rec["id"],
|
||||
"user_id": rec["user_id"],
|
||||
"name": name,
|
||||
"class_date": str(rec["class_date"]),
|
||||
"checked_at": fmt_dt(rec.get("checked_at")),
|
||||
"status": rec.get("status", DEFAULT_STATUS),
|
||||
"course_type": rec.get("course_type", DEFAULT_COURSE_TYPE),
|
||||
"note": rec.get("note", ""),
|
||||
}
|
||||
|
||||
|
||||
class CheckinRequest(BaseModel):
|
||||
status: str = DEFAULT_STATUS
|
||||
class_date: str = ""
|
||||
note: str = ""
|
||||
course_type: str = DEFAULT_COURSE_TYPE
|
||||
|
||||
|
||||
@router.get("/week")
|
||||
def week(user=Depends(get_current_user)):
|
||||
class_date = str(get_class_date())
|
||||
rec = db.fetch_one(
|
||||
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
|
||||
(user["id"], class_date),
|
||||
)
|
||||
return {"class_date": class_date, "checked": rec is not None, "record": fmt_rec(rec) if rec else None}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def checkin(req: CheckinRequest, user=Depends(get_current_user)):
|
||||
status = req.status.strip()
|
||||
if status not in CHECKIN_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="无效的打卡状态")
|
||||
|
||||
class_date = req.class_date.strip() or str(get_class_date())
|
||||
if not _is_valid_date(class_date):
|
||||
raise HTTPException(status_code=400, detail="无效的上课日期")
|
||||
|
||||
note = req.note.strip() if status != DEFAULT_STATUS else ""
|
||||
if len(note) > MAX_NOTE_LEN:
|
||||
raise HTTPException(status_code=400, detail="备注过长")
|
||||
|
||||
now = datetime.now()
|
||||
# 根据 user_id 判断当天是否已打卡:已打卡 → 更新;未打卡 → 新增
|
||||
existing = db.fetch_one(
|
||||
"SELECT id FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
|
||||
(user["id"], class_date),
|
||||
)
|
||||
if existing:
|
||||
db.execute(
|
||||
"UPDATE checkin_records_old SET checked_at=%s, status=%s, course_type=%s, note=%s WHERE id=%s",
|
||||
(now, status, DEFAULT_COURSE_TYPE, note, existing["id"]),
|
||||
)
|
||||
else:
|
||||
# course_type 固定为会员课程(规范),忽略客户端传入值
|
||||
db.execute(
|
||||
"INSERT INTO checkin_records_old (user_id, class_date, checked_at, status, course_type, note) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s)",
|
||||
(user["id"], class_date, now, status, DEFAULT_COURSE_TYPE, note),
|
||||
)
|
||||
rec = db.fetch_one(
|
||||
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
|
||||
(user["id"], class_date),
|
||||
)
|
||||
return {"success": True, "record": fmt_rec(rec, user.get("name", "")) if rec else None}
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
def list_records(page: int = 1, size: int = 10, user=Depends(get_current_user)):
|
||||
page = max(1, page)
|
||||
size = max(1, min(50, size))
|
||||
offset = (page - 1) * size
|
||||
total_row = db.fetch_one(
|
||||
"SELECT COUNT(*) AS cnt FROM checkin_records_old WHERE user_id=%s", (user["id"],)
|
||||
)
|
||||
total = total_row["cnt"] if total_row else 0
|
||||
rows = db.fetch_all(
|
||||
"SELECT " + SELECT_COLS + " FROM checkin_records_old "
|
||||
"WHERE user_id=%s ORDER BY class_date DESC LIMIT %s OFFSET %s",
|
||||
(user["id"], size, offset),
|
||||
)
|
||||
return {"list": [fmt_rec(r, user.get("name", "")) for r in rows], "total": total, "page": page, "size": size}
|
||||
|
||||
|
||||
@router.get("/group")
|
||||
def group_view(class_date: str = "", user=Depends(get_current_user)):
|
||||
class_date = class_date.strip() or str(get_class_date())
|
||||
if not _is_valid_date(class_date):
|
||||
raise HTTPException(status_code=400, detail="无效的上课日期")
|
||||
|
||||
user_row = db.fetch_one("SELECT `group` FROM auth_users WHERE id=%s", (user["id"],))
|
||||
if not user_row or not user_row.get("group"):
|
||||
return {"class_date": class_date, "group_name": "", "members": []}
|
||||
|
||||
group_name = user_row["group"]
|
||||
# 单查询:成员 + 同组成员该上课日的打卡记录,LEFT JOIN 一次取回
|
||||
rows = db.fetch_all(
|
||||
"SELECT m.id AS user_id, m.student_id, m.name, "
|
||||
" r.status, r.checked_at, r.note "
|
||||
"FROM auth_users m "
|
||||
"LEFT JOIN checkin_records_old r "
|
||||
" ON r.user_id = m.id AND r.class_date = %s "
|
||||
"WHERE m.`group` = %s "
|
||||
"ORDER BY m.student_id",
|
||||
(class_date, group_name),
|
||||
)
|
||||
|
||||
result_members = []
|
||||
for row in rows:
|
||||
rec = None
|
||||
if row.get("status") is not None:
|
||||
rec = {
|
||||
"status": row["status"],
|
||||
"checked_at": fmt_dt(row.get("checked_at")),
|
||||
"note": row.get("note", ""),
|
||||
}
|
||||
result_members.append({
|
||||
"user_id": row["user_id"],
|
||||
"student_id": row["student_id"],
|
||||
"name": row["name"],
|
||||
"role": "",
|
||||
"checked": rec is not None,
|
||||
"record": rec,
|
||||
})
|
||||
return {"class_date": class_date, "group_name": group_name, "members": result_members}
|
||||
86
routes/course_vote.py
Normal file
86
routes/course_vote.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import config
|
||||
from .auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["course-vote"])
|
||||
|
||||
|
||||
def _load():
|
||||
if not os.path.exists(config.COURSE_VOTES_FILE):
|
||||
return {}
|
||||
with open(config.COURSE_VOTES_FILE, encoding="utf-8") as f:
|
||||
content = f.read().strip()
|
||||
return json.loads(content) if content else {}
|
||||
|
||||
|
||||
def _save(data):
|
||||
os.makedirs(os.path.dirname(config.COURSE_VOTES_FILE), exist_ok=True)
|
||||
with open(config.COURSE_VOTES_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
class CourseVoteRequest(BaseModel):
|
||||
votes: list = []
|
||||
rating: int = 0
|
||||
comment: str = ""
|
||||
|
||||
|
||||
@router.post("/course-vote")
|
||||
def submit_vote(req: CourseVoteRequest, user=Depends(get_current_user)):
|
||||
votes = req.votes
|
||||
if not votes or len(votes) < 3 or len(votes) > 6:
|
||||
raise HTTPException(status_code=400, detail="请选择3-6门课程")
|
||||
data = _load()
|
||||
data[user["student_id"]] = {
|
||||
"name": user["name"],
|
||||
"votes": votes,
|
||||
"rating": req.rating,
|
||||
"comment": req.comment.strip()[:500],
|
||||
"time": str(datetime.now()),
|
||||
}
|
||||
_save(data)
|
||||
return {"success": True, "msg": "投票成功"}
|
||||
|
||||
|
||||
@router.get("/course-votes")
|
||||
def get_votes(user=Depends(get_current_user)):
|
||||
data = _load()
|
||||
totals = {}
|
||||
reviews = []
|
||||
total_rating = 0
|
||||
rating_count = 0
|
||||
for sid, info in data.items():
|
||||
for v in info.get("votes", []):
|
||||
totals[v] = totals.get(v, 0) + 1
|
||||
r = info.get("rating", 0)
|
||||
t = info.get("time", "")
|
||||
if t and " " in t:
|
||||
t = t.split(" ")[0]
|
||||
if r > 0:
|
||||
total_rating += r
|
||||
rating_count += 1
|
||||
reviews.append({"name": info.get("name", sid), "rating": r, "comment": info.get("comment", ""), "time": t})
|
||||
my_votes = []
|
||||
my_rating = 0
|
||||
my_comment = ""
|
||||
if user["student_id"] in data:
|
||||
my_votes = data[user["student_id"]].get("votes", [])
|
||||
my_rating = data[user["student_id"]].get("rating", 0)
|
||||
my_comment = data[user["student_id"]].get("comment", "")
|
||||
avg_score = round(total_rating / rating_count, 1) if rating_count > 0 else 0
|
||||
return {
|
||||
"success": True,
|
||||
"totals": totals,
|
||||
"reviews": reviews,
|
||||
"avgScore": avg_score,
|
||||
"myVotes": my_votes,
|
||||
"myRating": my_rating,
|
||||
"myComment": my_comment,
|
||||
"totalVoters": len(data),
|
||||
}
|
||||
109
routes/feedback.py
Normal file
109
routes/feedback.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
|
||||
|
||||
|
||||
def _image_dir():
|
||||
return os.path.join(config.FEEDBACK_DIR, "images")
|
||||
|
||||
|
||||
def _ensure_dirs():
|
||||
os.makedirs(config.FEEDBACK_DIR, exist_ok=True)
|
||||
os.makedirs(_image_dir(), exist_ok=True)
|
||||
|
||||
|
||||
def _load_items():
|
||||
items = []
|
||||
for fn in os.listdir(config.FEEDBACK_DIR):
|
||||
if fn.endswith(".json"):
|
||||
path = os.path.join(config.FEEDBACK_DIR, fn)
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
items.append(json.load(f))
|
||||
except Exception:
|
||||
logger.exception("读取反馈数据文件失败: %s", path)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_feedback():
|
||||
items = _load_items()
|
||||
unsolved = sorted([x for x in items if x.get("status") == "unsolved"], key=lambda x: x["created_at"], reverse=True)
|
||||
solved = sorted([x for x in items if x.get("status") != "unsolved"], key=lambda x: x["created_at"], reverse=True)
|
||||
return unsolved + solved
|
||||
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
image: str = ""
|
||||
|
||||
|
||||
@router.post("")
|
||||
def submit_feedback(req: FeedbackRequest):
|
||||
_ensure_dirs()
|
||||
fid = os.urandom(4).hex()
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
item = {
|
||||
"id": fid,
|
||||
"name": req.name.strip() or "匿名",
|
||||
"description": req.description.strip(),
|
||||
"status": "unsolved",
|
||||
"created_at": now,
|
||||
}
|
||||
img = req.image
|
||||
if img.startswith("data:image/"):
|
||||
try:
|
||||
fmt = re.sub(r"[^a-zA-Z0-9]", "", img.split(";")[0].split("/")[1])
|
||||
with open(os.path.join(_image_dir(), fid + "." + fmt), "wb") as f:
|
||||
f.write(base64.b64decode(img.split(",")[1]))
|
||||
item["image"] = "/api/v1/feedback/file/" + fid + "." + fmt
|
||||
except Exception:
|
||||
logger.exception("保存反馈图片失败: %s", fid)
|
||||
with open(os.path.join(config.FEEDBACK_DIR, fid + ".json"), "w", encoding="utf-8") as f:
|
||||
json.dump(item, f, ensure_ascii=False)
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/file/{name}")
|
||||
def get_file(name: str):
|
||||
safe_name = os.path.basename(name)
|
||||
path = os.path.join(_image_dir(), safe_name)
|
||||
if not os.path.exists(path):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
ext = os.path.splitext(safe_name)[1].lstrip(".").lower()
|
||||
media = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp"}.get(ext, "application/octet-stream")
|
||||
return FileResponse(path, media_type=media)
|
||||
|
||||
|
||||
@router.post("/resolve")
|
||||
def resolve(id: str = "", token: str = ""):
|
||||
safe_id = re.sub(r"[^a-zA-Z0-9]", "", id)
|
||||
if token != config.ADMIN_KEY:
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
if not safe_id:
|
||||
raise HTTPException(status_code=404, detail="未找到")
|
||||
path = os.path.join(config.FEEDBACK_DIR, safe_id + ".json")
|
||||
if not os.path.exists(path):
|
||||
raise HTTPException(status_code=404, detail="未找到")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
item = json.load(f)
|
||||
item["status"] = "solved"
|
||||
item["resolved_at"] = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(item, f, ensure_ascii=False)
|
||||
return {"ok": True, "id": safe_id}
|
||||
87
routes/pages.py
Normal file
87
routes/pages.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from .. import config
|
||||
from ..user import user_payload
|
||||
from .auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/v1/pages", tags=["pages"])
|
||||
|
||||
|
||||
def _greeting():
|
||||
hour = datetime.now().hour
|
||||
if hour < 6:
|
||||
return "夜深了,还在用功"
|
||||
if hour < 9:
|
||||
return "早上好,今天又起了一个大早"
|
||||
if hour < 12:
|
||||
return "上午好"
|
||||
if hour < 14:
|
||||
return "中午好,记得吃饭"
|
||||
if hour < 18:
|
||||
return "下午好"
|
||||
if hour < 21:
|
||||
return "晚上好"
|
||||
return "夜深了,早点休息"
|
||||
|
||||
|
||||
def _xinhuo_member(sid):
|
||||
return sid.startswith("N") or sid.startswith("0")
|
||||
|
||||
|
||||
def _norm_group(group):
|
||||
g = (group or "").replace("组", "").strip()
|
||||
if len(g) == 1:
|
||||
g = "0" + g
|
||||
return g
|
||||
|
||||
|
||||
@router.get("/home")
|
||||
def home(user=Depends(get_current_user)):
|
||||
base = config.H5_BASE_URL
|
||||
role = user.get("role", "student")
|
||||
is_teacher = role == "teacher"
|
||||
sid = user.get("student_id", "")
|
||||
payload = user_payload(user)
|
||||
group = payload["group"]
|
||||
periods = {cg.get("period") for cg in payload["class_groups"]}
|
||||
|
||||
def period_url(period, group_name):
|
||||
if is_teacher:
|
||||
return base + "/" + period + "/"
|
||||
g = _norm_group(group_name)
|
||||
if g.isdigit():
|
||||
return base + "/" + period + "/" + g + "-组.html"
|
||||
return base + "/" + period + "/自由小组.html"
|
||||
|
||||
modules = [
|
||||
{"id": "xinhuo", "type": "period", "icon": "🔥", "label": "薪火相传", "desc": "年课 / 终身学习小组",
|
||||
"url": base + "/xinhuo/" if is_teacher else base + "/xinhuo/index.html",
|
||||
"visible": is_teacher or _xinhuo_member(sid)},
|
||||
{"id": "19qi", "type": "period", "icon": "📖", "label": "19期研修", "desc": "小班课 · 第19期",
|
||||
"url": period_url("19qi", group), "visible": is_teacher or ("19期" in periods)},
|
||||
{"id": "18qi", "type": "period", "icon": "📖", "label": "18期研修", "desc": "小班课 · 第18期",
|
||||
"url": period_url("18qi", group), "visible": is_teacher or ("18期" in periods)},
|
||||
{"id": "20qi", "type": "period", "icon": "📖", "label": "20期小班课", "desc": "小班课 · 第20期",
|
||||
"url": period_url("20qi", group), "visible": is_teacher or ("20期" in periods)},
|
||||
{"id": "teacher", "type": "teacher", "icon": "🎓", "label": "老师大班授权", "desc": "年课 / 长期学员入口",
|
||||
"url": base + "/teacher/" if (is_teacher or _xinhuo_member(sid)) else base + "/xinhuo/upgrade-info.html",
|
||||
"visible": True},
|
||||
{"id": "student360", "type": "profile", "icon": "🧬", "label": "学员360", "desc": "薪火相传 · 个人成长档案",
|
||||
"url": base + "/xinhuo360/index.html" if is_teacher else base + "/student360/pages/" + sid + "-" + payload["name"] + ".html",
|
||||
"visible": True},
|
||||
{"id": "course-matrix", "type": "course", "icon": "🧠", "label": "AI量化课程矩阵", "desc": "12门课程 · 选出你最想学的3门",
|
||||
"url": base + "/course-matrix.html", "visible": True},
|
||||
{"id": "demo", "type": "demo", "icon": "🧭", "label": "功能演示", "desc": "先看看讨论和量化报告的样子",
|
||||
"url": base + "/xinhuo/virtual-group.html", "visible": True},
|
||||
{"id": "yijing", "type": "study", "icon": "☯", "label": "易经学习", "desc": "周易六十四卦 · 经文详解",
|
||||
"url": base + "/yijing/", "visible": True},
|
||||
]
|
||||
|
||||
return {
|
||||
"greeting": _greeting(),
|
||||
"display_name": payload["display_name"],
|
||||
"encouragement": "日拱一卒,功不唐捐。",
|
||||
"modules": [m for m in modules if m["visible"]],
|
||||
}
|
||||
474
routes/plan.py
Normal file
474
routes/plan.py
Normal file
@@ -0,0 +1,474 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import config, db
|
||||
from .auth import get_current_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/plan", tags=["plan"])
|
||||
|
||||
PLAN_TYPES = {"weekly", "monthly", "yearly"}
|
||||
PLAN_STATUSES = {"已完成", "未完成"}
|
||||
DEFAULT_STATUS = "未完成"
|
||||
MAX_CONTENT_LEN = 1000
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
MAX_FILES = 4
|
||||
ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
|
||||
ATTACH_DIR = config.PLAN_ATTACH_DIR
|
||||
SELECT_COLS = "id,user_id,plan_type,period_key,status,checked_at,content,attachments,created_at,updated_at"
|
||||
|
||||
CONTENT_TYPES = {
|
||||
"pdf": "application/pdf", "png": "image/png", "jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp",
|
||||
"bmp": "image/bmp", "doc": "application/msword",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
}
|
||||
|
||||
|
||||
def iso_week_key(day):
|
||||
iso = day.isocalendar()
|
||||
return "%d-W%02d" % (iso[0], iso[1])
|
||||
|
||||
|
||||
def parse_iso_week(period_key):
|
||||
parts = period_key.split("-W")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
try:
|
||||
year = int(parts[0])
|
||||
week = int(parts[1])
|
||||
except ValueError as exc:
|
||||
logger.warning("解析 ISO 周周期失败: %s", exc)
|
||||
return None
|
||||
jan4 = date(year, 1, 4)
|
||||
monday = jan4 - timedelta(days=jan4.isoweekday() - 1)
|
||||
return monday + timedelta(weeks=week - 1)
|
||||
|
||||
|
||||
def default_weekly_period(today=None):
|
||||
today = today or date.today()
|
||||
if today.weekday() >= 1:
|
||||
return iso_week_key(today + timedelta(days=7))
|
||||
return iso_week_key(today)
|
||||
|
||||
|
||||
def weekly_window(period_key):
|
||||
monday = parse_iso_week(period_key)
|
||||
if not monday:
|
||||
return None, None
|
||||
open_at = datetime.combine(monday - timedelta(days=3), time.min)
|
||||
deadline = datetime.combine(monday + timedelta(days=1), time(23, 59, 59))
|
||||
return open_at, deadline
|
||||
|
||||
|
||||
def weekly_submit_state(period_key):
|
||||
open_at, deadline = weekly_window(period_key)
|
||||
if not open_at or not deadline:
|
||||
return False, "无效的周周期"
|
||||
now = datetime.now()
|
||||
if now < open_at:
|
||||
return False, "周五开始填写"
|
||||
if now > deadline:
|
||||
return False, "已过截止时间"
|
||||
return True, ""
|
||||
|
||||
|
||||
def get_period_key(plan_type):
|
||||
today = date.today()
|
||||
if plan_type == "weekly":
|
||||
return default_weekly_period(today)
|
||||
if plan_type == "monthly":
|
||||
return today.strftime("%Y-%m")
|
||||
if plan_type == "yearly":
|
||||
return today.strftime("%Y")
|
||||
return ""
|
||||
|
||||
|
||||
def fmt_dt(value):
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(value) if value else ""
|
||||
|
||||
|
||||
def _parse_attachments(raw):
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
logger.exception("计划附件字段解析失败")
|
||||
return []
|
||||
return raw or []
|
||||
|
||||
|
||||
def fmt_rec(rec):
|
||||
return {
|
||||
"id": rec["id"],
|
||||
"user_id": rec["user_id"],
|
||||
"plan_type": rec["plan_type"],
|
||||
"period_key": rec["period_key"],
|
||||
"status": rec.get("status", DEFAULT_STATUS),
|
||||
"checked_at": fmt_dt(rec.get("checked_at")),
|
||||
"content": rec.get("content", ""),
|
||||
"attachments": _parse_attachments(rec.get("attachments")),
|
||||
"created_at": fmt_dt(rec.get("created_at")),
|
||||
"updated_at": fmt_dt(rec.get("updated_at")),
|
||||
}
|
||||
|
||||
|
||||
class PlanCheckinRequest(BaseModel):
|
||||
plan_type: str = ""
|
||||
period_key: str = ""
|
||||
status: str = DEFAULT_STATUS
|
||||
content: str = ""
|
||||
attachments: List[str] = []
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def status(type: str, period_key: str = "", user=Depends(get_current_user)):
|
||||
if type not in PLAN_TYPES:
|
||||
raise HTTPException(status_code=400, detail="无效的计划类型")
|
||||
period_key = period_key.strip() or get_period_key(type)
|
||||
if type == "weekly":
|
||||
if parse_iso_week(period_key) is None:
|
||||
raise HTTPException(status_code=400, detail="无效的周周期")
|
||||
if period_key != default_weekly_period(date.today()):
|
||||
raise HTTPException(status_code=400, detail="仅可查询当前周期")
|
||||
rec = db.fetch_one(
|
||||
"SELECT " + SELECT_COLS + " FROM plan_checkins WHERE user_id=%s AND plan_type=%s AND period_key=%s",
|
||||
(user["id"], type, period_key),
|
||||
)
|
||||
can_submit, message = (True, "")
|
||||
if type == "weekly":
|
||||
can_submit, message = weekly_submit_state(period_key)
|
||||
return {"plan_type": type, "period_key": period_key, "checked": rec is not None,
|
||||
"can_submit": can_submit, "message": message,
|
||||
"record": fmt_rec(rec) if rec else None}
|
||||
|
||||
|
||||
@router.get("/weekly-groups")
|
||||
def weekly_groups(period_key: str = "", user=Depends(get_current_user)):
|
||||
selected, _can_submit, _message = _validate_current_period("weekly", period_key)
|
||||
stats = _groups_stats("weekly", selected)
|
||||
groups = [
|
||||
{
|
||||
"group_name": g["group_name"],
|
||||
"checked_count": g["checked_count"],
|
||||
"unchecked_count": g["unchecked_count"],
|
||||
"total": g["total"],
|
||||
}
|
||||
for g in stats
|
||||
]
|
||||
return {"period_key": selected, "groups": groups}
|
||||
|
||||
|
||||
@router.get("/weekly-group")
|
||||
def weekly_group(group_name: str = "", period_key: str = "", user=Depends(get_current_user)):
|
||||
default_period = default_weekly_period(date.today())
|
||||
selected = period_key.strip() or default_period
|
||||
if selected != default_period:
|
||||
raise HTTPException(status_code=400, detail="仅可查看当前周期")
|
||||
group_name = group_name.strip()
|
||||
if not group_name:
|
||||
raise HTTPException(status_code=400, detail="缺少小组名")
|
||||
group_row = db.fetch_one("SELECT `group` FROM auth_users WHERE `group`=%s LIMIT 1", (group_name,))
|
||||
if not group_row:
|
||||
raise HTTPException(status_code=404, detail="小组不存在")
|
||||
|
||||
can_submit, message = weekly_submit_state(default_period)
|
||||
members = db.fetch_all(
|
||||
"SELECT id AS user_id, name FROM auth_users WHERE `group`=%s ORDER BY id",
|
||||
(group_name,),
|
||||
)
|
||||
uids = [m["user_id"] for m in members]
|
||||
rec_map = {}
|
||||
if uids:
|
||||
placeholders = ",".join(["%s"] * len(uids))
|
||||
records = db.fetch_all(
|
||||
"SELECT user_id, status, checked_at FROM plan_checkins "
|
||||
"WHERE plan_type='weekly' AND period_key=%s AND user_id IN (" + placeholders + ")",
|
||||
tuple([selected] + uids),
|
||||
)
|
||||
rec_map = {r["user_id"]: r for r in records}
|
||||
|
||||
result = []
|
||||
for m in members:
|
||||
rec = rec_map.get(m["user_id"])
|
||||
result.append({
|
||||
"user_id": m["user_id"],
|
||||
"name": m["name"],
|
||||
"filled": rec is not None,
|
||||
"record": {
|
||||
"status": rec["status"],
|
||||
"checked_at": fmt_dt(rec.get("checked_at")),
|
||||
} if rec else None,
|
||||
})
|
||||
result.sort(key=lambda item: (0 if not item["filled"] else 1, item["user_id"]))
|
||||
return {
|
||||
"period_key": selected,
|
||||
"group_name": group_name,
|
||||
"can_submit": can_submit,
|
||||
"message": message,
|
||||
"members": result,
|
||||
}
|
||||
|
||||
|
||||
def _validate_current_period(plan_type, period_key):
|
||||
"""Normalize to the current period for the given plan type.
|
||||
|
||||
Returns (selected, can_submit, message); raises 400 for invalid types/periods.
|
||||
"""
|
||||
selected = period_key.strip() or get_period_key(plan_type)
|
||||
if plan_type == "weekly":
|
||||
if parse_iso_week(selected) is None:
|
||||
raise HTTPException(status_code=400, detail="无效的周周期")
|
||||
if selected != default_weekly_period(date.today()):
|
||||
raise HTTPException(status_code=400, detail="仅可查看当前周期")
|
||||
can_submit, message = weekly_submit_state(selected)
|
||||
else:
|
||||
if selected != get_period_key(plan_type):
|
||||
raise HTTPException(status_code=400, detail="仅可查看当前周期")
|
||||
can_submit, message = (True, "")
|
||||
return selected, can_submit, message
|
||||
|
||||
|
||||
def _groups_stats(plan_type, selected):
|
||||
"""单条 JOIN 聚合 SQL:提交计划即已完成,未提交即未完成。
|
||||
|
||||
按小组统计总人数 / 已完成(有记录)/ 未完成(无记录),避免 N+1 查询。
|
||||
"""
|
||||
rows = db.fetch_all(
|
||||
"SELECT u.`group` AS group_name, "
|
||||
"COUNT(u.id) AS total, "
|
||||
"SUM(CASE WHEN pc.user_id IS NOT NULL THEN 1 ELSE 0 END) AS checked_count "
|
||||
"FROM auth_users u "
|
||||
"LEFT JOIN plan_checkins pc ON pc.user_id = u.id "
|
||||
"AND pc.plan_type = %s AND pc.period_key = %s "
|
||||
"WHERE u.`group` IS NOT NULL AND u.`group` <> '' "
|
||||
"GROUP BY u.`group` ORDER BY u.`group`",
|
||||
(plan_type, selected),
|
||||
)
|
||||
groups_list = []
|
||||
for item in rows or []:
|
||||
total = item.get("total") or 0
|
||||
checked = item.get("checked_count") or 0
|
||||
groups_list.append({
|
||||
"group_name": item.get("group_name") or "",
|
||||
"total": total,
|
||||
"checked_count": checked,
|
||||
"unchecked_count": total - checked,
|
||||
})
|
||||
return groups_list
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
|
||||
if type not in PLAN_TYPES:
|
||||
raise HTTPException(status_code=400, detail="无效的计划类型")
|
||||
selected, can_submit, message = _validate_current_period(type, period_key)
|
||||
|
||||
my_rec = db.fetch_one(
|
||||
"SELECT " + SELECT_COLS + " FROM plan_checkins "
|
||||
"WHERE user_id=%s AND plan_type=%s AND period_key=%s",
|
||||
(user["id"], type, selected),
|
||||
)
|
||||
my_checked = my_rec is not None
|
||||
my_status = (my_rec or {}).get("status", "")
|
||||
|
||||
groups_list = _groups_stats(type, selected)
|
||||
my_group = (user.get("group") or "").strip()
|
||||
if my_group:
|
||||
mine = [g for g in groups_list if g["group_name"] == my_group]
|
||||
others = [g for g in groups_list if g["group_name"] != my_group]
|
||||
groups_list = mine + others
|
||||
|
||||
unassigned_row = db.fetch_one(
|
||||
"SELECT COUNT(*) AS cnt FROM auth_users WHERE `group` IS NULL OR `group` = ''"
|
||||
)
|
||||
unassigned_count = unassigned_row["cnt"] if unassigned_row else 0
|
||||
|
||||
return {
|
||||
"type": type,
|
||||
"period_key": selected,
|
||||
"can_submit": can_submit,
|
||||
"message": message,
|
||||
"my_checked": my_checked,
|
||||
"my_status": my_status,
|
||||
"my_record": fmt_rec(my_rec) if my_rec else None,
|
||||
"unassigned_count": unassigned_count,
|
||||
"groups": groups_list,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/group")
|
||||
def group(type: str, group_name: str, period_key: str = "", user=Depends(get_current_user)):
|
||||
if type not in PLAN_TYPES:
|
||||
raise HTTPException(status_code=400, detail="无效的计划类型")
|
||||
selected, can_submit, message = _validate_current_period(type, period_key)
|
||||
group_name = group_name.strip()
|
||||
if not group_name:
|
||||
raise HTTPException(status_code=400, detail="缺少小组名")
|
||||
group_row = db.fetch_one("SELECT `group` FROM auth_users WHERE `group`=%s LIMIT 1", (group_name,))
|
||||
if not group_row:
|
||||
raise HTTPException(status_code=404, detail="小组不存在")
|
||||
|
||||
members = db.fetch_all(
|
||||
"SELECT id AS user_id, name FROM auth_users WHERE `group`=%s ORDER BY id",
|
||||
(group_name,),
|
||||
)
|
||||
uids = [m["user_id"] for m in members]
|
||||
rec_map = {}
|
||||
if uids:
|
||||
placeholders = ",".join(["%s"] * len(uids))
|
||||
records = db.fetch_all(
|
||||
"SELECT user_id, status, checked_at FROM plan_checkins "
|
||||
"WHERE plan_type=%s AND period_key=%s AND user_id IN (" + placeholders + ")",
|
||||
tuple([type, selected] + uids),
|
||||
)
|
||||
rec_map = {r["user_id"]: r for r in records}
|
||||
|
||||
result = []
|
||||
for m in members:
|
||||
rec = rec_map.get(m["user_id"])
|
||||
result.append({
|
||||
"user_id": m["user_id"],
|
||||
"name": m["name"],
|
||||
"filled": rec is not None,
|
||||
"record": {
|
||||
"status": rec["status"],
|
||||
"checked_at": fmt_dt(rec.get("checked_at")),
|
||||
} if rec else None,
|
||||
})
|
||||
result.sort(key=lambda item: (0 if not item["filled"] else 1, item["user_id"]))
|
||||
return {
|
||||
"type": type,
|
||||
"period_key": selected,
|
||||
"group_name": group_name,
|
||||
"can_submit": can_submit,
|
||||
"message": message,
|
||||
"members": result,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/checkin")
|
||||
def checkin(req: PlanCheckinRequest, user=Depends(get_current_user)):
|
||||
plan_type = req.plan_type.strip()
|
||||
if plan_type not in PLAN_TYPES:
|
||||
raise HTTPException(status_code=400, detail="无效的计划类型")
|
||||
period_key = req.period_key.strip() or get_period_key(plan_type)
|
||||
if plan_type == "weekly":
|
||||
if parse_iso_week(period_key) is None:
|
||||
raise HTTPException(status_code=400, detail="无效的周周期")
|
||||
default_period = default_weekly_period(date.today())
|
||||
if period_key != default_period:
|
||||
raise HTTPException(status_code=400, detail="仅可填写当前周期")
|
||||
can_submit, message = weekly_submit_state(default_period)
|
||||
if not can_submit:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
status = req.status.strip()
|
||||
if status not in PLAN_STATUSES:
|
||||
status = DEFAULT_STATUS
|
||||
content = req.content.strip()
|
||||
if len(content) > MAX_CONTENT_LEN:
|
||||
content = content[:MAX_CONTENT_LEN]
|
||||
attachments = [a for a in req.attachments if isinstance(a, str) and a.strip()][:MAX_FILES]
|
||||
attachments_json = json.dumps(attachments, ensure_ascii=False)
|
||||
now = datetime.now()
|
||||
db.execute(
|
||||
"INSERT INTO plan_checkins (user_id, plan_type, period_key, status, checked_at, content, attachments) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s) "
|
||||
"ON DUPLICATE KEY UPDATE status=VALUES(status), checked_at=VALUES(checked_at), "
|
||||
"content=VALUES(content), attachments=VALUES(attachments)",
|
||||
(user["id"], plan_type, period_key, status, now, content, attachments_json),
|
||||
)
|
||||
rec = db.fetch_one(
|
||||
"SELECT " + SELECT_COLS + " FROM plan_checkins WHERE user_id=%s AND plan_type=%s AND period_key=%s",
|
||||
(user["id"], plan_type, period_key),
|
||||
)
|
||||
return {"success": True, "record": fmt_rec(rec)}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def history(type: str, page: int = 1, size: int = 10, user=Depends(get_current_user)):
|
||||
if type not in PLAN_TYPES:
|
||||
raise HTTPException(status_code=400, detail="无效的计划类型")
|
||||
page = max(1, page)
|
||||
size = max(1, min(50, size))
|
||||
offset = (page - 1) * size
|
||||
total_row = db.fetch_one(
|
||||
"SELECT COUNT(*) AS cnt FROM plan_checkins WHERE user_id=%s AND plan_type=%s",
|
||||
(user["id"], type),
|
||||
)
|
||||
total = total_row["cnt"] if total_row else 0
|
||||
rows = db.fetch_all(
|
||||
"SELECT " + SELECT_COLS + " FROM plan_checkins WHERE user_id=%s AND plan_type=%s "
|
||||
"ORDER BY period_key DESC LIMIT %s OFFSET %s",
|
||||
(user["id"], type, size, offset),
|
||||
)
|
||||
return {"list": [fmt_rec(r) for r in rows], "total": total, "page": page, "size": size}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
def upload(
|
||||
plan_type: str = Form(...),
|
||||
period_key: str = Form(""),
|
||||
files: List[UploadFile] = File(...),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
plan_type = plan_type.strip()
|
||||
if plan_type not in PLAN_TYPES:
|
||||
raise HTTPException(status_code=400, detail="无效的计划类型")
|
||||
if len(files) > MAX_FILES:
|
||||
raise HTTPException(status_code=400, detail="最多上传4个文件")
|
||||
period_key = period_key.strip() or get_period_key(plan_type)
|
||||
save_dir = os.path.join(ATTACH_DIR, str(user["id"]), plan_type, period_key)
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
saved = []
|
||||
for f in files:
|
||||
data = f.file.read()
|
||||
if len(data) > MAX_FILE_SIZE:
|
||||
continue
|
||||
safe_name = os.path.basename(f.filename or "")
|
||||
if not safe_name:
|
||||
continue
|
||||
ext = os.path.splitext(safe_name)[1].lower()
|
||||
if ext not in ALLOWED_IMAGE_EXT:
|
||||
continue
|
||||
save_path = os.path.join(save_dir, safe_name)
|
||||
with open(save_path, "wb") as fh:
|
||||
fh.write(data)
|
||||
rel_path = "attachments/" + str(user["id"]) + "/" + plan_type + "/" + period_key + "/" + safe_name
|
||||
saved.append(rel_path)
|
||||
if not saved:
|
||||
raise HTTPException(status_code=400, detail="文件保存失败")
|
||||
return {"success": True, "attachments": saved}
|
||||
|
||||
|
||||
@router.get("/file/{sid}/{plan_type}/{period}/{filename}")
|
||||
def get_file(sid: str, plan_type: str, period: str, filename: str):
|
||||
safe_name = os.path.basename(filename)
|
||||
file_path = os.path.join(ATTACH_DIR, sid, plan_type, period, safe_name)
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
ext = os.path.splitext(safe_name)[1].lstrip(".").lower()
|
||||
media_type = CONTENT_TYPES.get(ext, "application/octet-stream")
|
||||
return FileResponse(file_path, media_type=media_type)
|
||||
|
||||
|
||||
@router.delete("/file/{sid}/{plan_type}/{period}/{filename}")
|
||||
def delete_file(sid: str, plan_type: str, period: str, filename: str, user=Depends(get_current_user)):
|
||||
if str(sid) != str(user["id"]):
|
||||
raise HTTPException(status_code=403, detail="无权限")
|
||||
safe_name = os.path.basename(filename)
|
||||
file_path = os.path.join(ATTACH_DIR, sid, plan_type, period, safe_name)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return {"success": True}
|
||||
83
routes/profile.py
Normal file
83
routes/profile.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import config, db
|
||||
from ..user import user_payload
|
||||
from .auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/v1/profile", tags=["profile"])
|
||||
|
||||
MAX_AVATAR_SIZE = 4 * 1024 * 1024
|
||||
ALLOWED_AVATAR_EXT = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
|
||||
|
||||
def _profile(user):
|
||||
payload = user_payload(user)
|
||||
return {
|
||||
"name": payload["name"],
|
||||
"student_id": payload["student_id"],
|
||||
"display_name": payload["display_name"],
|
||||
"wechat_nickname": user.get("wechat_nickname", ""),
|
||||
"phone": user.get("phone_display", "") or "",
|
||||
"group": payload["group"],
|
||||
"role": payload["role"],
|
||||
"class_groups": payload["class_groups"],
|
||||
"wechat_avatar": user.get("wechat_avatar", ""),
|
||||
"login_type": user.get("login_type", ""),
|
||||
}
|
||||
|
||||
|
||||
class ProfileUpdateRequest(BaseModel):
|
||||
wechat_nickname: str = ""
|
||||
wechat_avatar: str = ""
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_profile(user=Depends(get_current_user)):
|
||||
return {"success": True, **_profile(user)}
|
||||
|
||||
|
||||
@router.put("")
|
||||
def update_profile(req: ProfileUpdateRequest, user=Depends(get_current_user)):
|
||||
db.execute(
|
||||
"UPDATE auth_users SET wechat_nickname=%s, wechat_avatar=%s WHERE id=%s",
|
||||
(req.wechat_nickname, req.wechat_avatar, user["id"]),
|
||||
)
|
||||
fresh = db.fetch_one("SELECT * FROM auth_users WHERE id=%s", (user["id"],))
|
||||
return {"success": True, **_profile(fresh or user)}
|
||||
|
||||
|
||||
@router.post("/avatar")
|
||||
def upload_avatar(file: UploadFile = File(...), user=Depends(get_current_user)):
|
||||
data = file.file.read()
|
||||
if len(data) > MAX_AVATAR_SIZE:
|
||||
raise HTTPException(status_code=400, detail="头像不能超过4MB")
|
||||
|
||||
safe_name = os.path.basename(file.filename or "")
|
||||
ext = os.path.splitext(safe_name)[1].lower()
|
||||
if ext not in ALLOWED_AVATAR_EXT:
|
||||
raise HTTPException(status_code=400, detail="不支持的文件类型")
|
||||
|
||||
avatar_base = config.AVATAR_DIR
|
||||
uid = str(user["id"])
|
||||
save_dir = os.path.join(avatar_base, uid)
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
for old_name in os.listdir(save_dir):
|
||||
if old_name.startswith("avatar.") and old_name != "avatar" + ext:
|
||||
try:
|
||||
os.remove(os.path.join(save_dir, old_name))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
filename = "avatar" + ext
|
||||
file_path = os.path.join(save_dir, filename)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
url_path = "/clock/static/avatars/" + uid + "/" + filename
|
||||
|
||||
db.execute("UPDATE auth_users SET wechat_avatar=%s WHERE id=%s", (url_path, user["id"]))
|
||||
return {"success": True, "wechat_avatar": url_path}
|
||||
|
||||
81
routes/survey.py
Normal file
81
routes/survey.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import config, db
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/survey", tags=["survey"])
|
||||
|
||||
|
||||
def _fmt_row(row):
|
||||
if "answers" in row and isinstance(row["answers"], str):
|
||||
try:
|
||||
row["answers"] = json.loads(row["answers"])
|
||||
except Exception:
|
||||
logger.exception("survey answers 解析失败")
|
||||
row["answers"] = {}
|
||||
for key in ("created_at", "submitted_at", "updated_at"):
|
||||
row[key] = str(row.get(key)) if row.get(key) else ""
|
||||
return row
|
||||
|
||||
|
||||
class SurveyResponseRequest(BaseModel):
|
||||
task_id: int
|
||||
group_id: str
|
||||
answers: dict = {}
|
||||
|
||||
|
||||
class AnswersRequest(BaseModel):
|
||||
answers: dict = {}
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
def tasks():
|
||||
rows = db.fetch_all("SELECT * FROM survey_tasks WHERE status='active' ORDER BY task_no", db=config.SURVEY_DB)
|
||||
return [_fmt_row(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/responses")
|
||||
def responses(task_id: int):
|
||||
rows = db.fetch_all("SELECT * FROM survey_responses WHERE task_id=%s ORDER BY group_id", (task_id,), db=config.SURVEY_DB)
|
||||
return [_fmt_row(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/response")
|
||||
def response(req: SurveyResponseRequest):
|
||||
if not req.task_id or not req.group_id or not req.answers:
|
||||
raise HTTPException(status_code=400, detail="missing fields")
|
||||
answers_json = json.dumps(req.answers, ensure_ascii=False)
|
||||
existing = db.fetch_one(
|
||||
"SELECT id FROM survey_responses WHERE task_id=%s AND group_id=%s",
|
||||
(req.task_id, req.group_id), db=config.SURVEY_DB,
|
||||
)
|
||||
if existing:
|
||||
db.execute("UPDATE survey_responses SET answers=%s WHERE id=%s", (answers_json, existing["id"]), db=config.SURVEY_DB)
|
||||
rid = existing["id"]
|
||||
else:
|
||||
rid = db.execute(
|
||||
"INSERT INTO survey_responses (task_id,group_id,answers) VALUES (%s,%s,%s)",
|
||||
(req.task_id, req.group_id, answers_json), db=config.SURVEY_DB,
|
||||
)
|
||||
row = db.fetch_one("SELECT * FROM survey_responses WHERE id=%s", (rid,), db=config.SURVEY_DB)
|
||||
return _fmt_row(row)
|
||||
|
||||
|
||||
@router.put("/response/{rid}")
|
||||
def update_response(rid: int, req: AnswersRequest):
|
||||
db.execute("UPDATE survey_responses SET answers=%s WHERE id=%s", (json.dumps(req.answers, ensure_ascii=False), rid), db=config.SURVEY_DB)
|
||||
row = db.fetch_one("SELECT * FROM survey_responses WHERE id=%s", (rid,), db=config.SURVEY_DB)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return _fmt_row(row)
|
||||
|
||||
|
||||
@router.post("/task")
|
||||
def task():
|
||||
return {"msg": "功能预留,后续开放"}
|
||||
132
routes/yijing.py
Normal file
132
routes/yijing.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Yijing (易经) APIs, ported from the legacy Flask server to the unified FastAPI service.
|
||||
|
||||
All protected endpoints use the same Bearer JWT as the rest of the service, and
|
||||
the user_id is sourced from the authenticated auth_users row (old cookie sessions
|
||||
are handled on the H5 side by shrinking down to the token-less /api/auth/verify
|
||||
fallback before reaching these APIs).
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import db
|
||||
from .auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/v1/yijing", tags=["yijing"])
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
def yijing_catalog():
|
||||
"""Public catalog: trigrams + hexagrams, mirroring the legacy endpoint."""
|
||||
trigrams = db.fetch_all(
|
||||
"SELECT id,name,symbol,nature,de,family,direction,xiantian,houtian,binary_code,class_xiang,summary "
|
||||
"FROM yijing_trigrams ORDER BY xiantian"
|
||||
)
|
||||
hexagrams = db.fetch_all(
|
||||
"SELECT id,name,full_name,symbol,upper_trigram,lower_trigram,binary_code,judgment,gua_explanation,bagong,gong_wuxing "
|
||||
"FROM yijing_hexagrams ORDER BY id"
|
||||
)
|
||||
return {"success": True, "trigrams": trigrams, "hexagrams": hexagrams}
|
||||
|
||||
|
||||
class ProgressUpdateRequest(BaseModel):
|
||||
stage: int
|
||||
item_type: str
|
||||
item_id: int
|
||||
status: str = "learning"
|
||||
|
||||
|
||||
@router.get("/progress")
|
||||
def yijing_progress(user=Depends(get_current_user)):
|
||||
rows = db.fetch_all(
|
||||
"SELECT stage,item_type,item_id,status,score,attempts,correct "
|
||||
"FROM yijing_progress WHERE user_id=%s",
|
||||
(user["id"],),
|
||||
)
|
||||
by = {}
|
||||
for r in rows:
|
||||
by.setdefault(r["stage"], {})[
|
||||
f'{r["item_type"]}:{r["item_id"]}'
|
||||
] = {
|
||||
"status": r["status"],
|
||||
"score": r["score"],
|
||||
"attempts": r["attempts"],
|
||||
"correct": r["correct"],
|
||||
}
|
||||
return {"success": True, "progress": by}
|
||||
|
||||
|
||||
@router.post("/progress")
|
||||
def yijing_progress_update(req: ProgressUpdateRequest, user=Depends(get_current_user)):
|
||||
if not (1 <= req.stage <= 5) or not req.item_type or req.item_id <= 0:
|
||||
raise HTTPException(status_code=400, detail="参数错误")
|
||||
db.execute(
|
||||
"INSERT INTO yijing_progress (user_id,stage,item_type,item_id,status) "
|
||||
"VALUES (%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE status=VALUES(status)",
|
||||
(user["id"], req.stage, req.item_type, req.item_id, req.status),
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class QuizRequest(BaseModel):
|
||||
stage: int
|
||||
item_type: str
|
||||
item_id: int
|
||||
correct: bool = False
|
||||
answer: str = ""
|
||||
|
||||
|
||||
@router.post("/quiz")
|
||||
def yijing_quiz(req: QuizRequest, user=Depends(get_current_user)):
|
||||
if not (1 <= req.stage <= 5) or not req.item_type or req.item_id <= 0:
|
||||
raise HTTPException(status_code=400, detail="参数错误")
|
||||
|
||||
correct = 1 if req.correct else 0
|
||||
answer = req.answer[:500]
|
||||
db.execute(
|
||||
"INSERT INTO yijing_attempts (user_id,stage,item_type,item_id,correct,answer) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s)",
|
||||
(user["id"], req.stage, req.item_type, req.item_id, correct, answer),
|
||||
)
|
||||
|
||||
row = db.fetch_one(
|
||||
"SELECT score,correct FROM yijing_progress "
|
||||
"WHERE user_id=%s AND stage=%s AND item_type=%s AND item_id=%s",
|
||||
(user["id"], req.stage, req.item_type, req.item_id),
|
||||
)
|
||||
if not row:
|
||||
db.execute(
|
||||
"INSERT INTO yijing_progress (user_id,stage,item_type,item_id,status,score,attempts,correct) "
|
||||
"VALUES (%s,%s,%s,%s,'learning',%s,1,%s)",
|
||||
(user["id"], req.stage, req.item_type, req.item_id, correct, correct),
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
"UPDATE yijing_progress SET attempts=attempts+1, correct=correct+%s, "
|
||||
"score=LEAST(correct+%s,2) WHERE user_id=%s AND stage=%s AND item_type=%s AND item_id=%s",
|
||||
(correct, correct, user["id"], req.stage, req.item_type, req.item_id),
|
||||
)
|
||||
|
||||
row = db.fetch_one(
|
||||
"SELECT score,correct FROM yijing_progress "
|
||||
"WHERE user_id=%s AND stage=%s AND item_type=%s AND item_id=%s",
|
||||
(user["id"], req.stage, req.item_type, req.item_id),
|
||||
)
|
||||
mastered = False
|
||||
if row and row["score"] >= 2:
|
||||
db.execute(
|
||||
"UPDATE yijing_progress SET status='mastered' "
|
||||
"WHERE user_id=%s AND stage=%s AND item_type=%s AND item_id=%s",
|
||||
(user["id"], req.stage, req.item_type, req.item_id),
|
||||
)
|
||||
mastered = True
|
||||
|
||||
due = datetime.now() + (timedelta(days=1) if correct else timedelta(hours=1))
|
||||
db.execute(
|
||||
"INSERT INTO yijing_reviews (user_id,item_type,item_id,due_at) VALUES (%s,%s,%s,%s) "
|
||||
"ON DUPLICATE KEY UPDATE due_at=VALUES(due_at)",
|
||||
(user["id"], req.item_type, req.item_id, due),
|
||||
)
|
||||
|
||||
return {"success": True, "mastered": mastered, "score": row["score"] if row else 0}
|
||||
32
security.py
Normal file
32
security.py
Normal 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
37
start.sh
Normal 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
42
stop.sh
Normal 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
47
user.py
Normal 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
124
uvicorn.log
Normal 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
55
wechat.py
Normal 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")
|
||||
Reference in New Issue
Block a user