Files
wechat_backend/routes/auth.py
王宏豪 faa114503f feat(api): 修改oss
- 替换开发环境API基础URL为本地服务地址
- 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题
- 添加缓存清理功能,包括过期清理和按需删除特定缓存项
- 集成阿里云OSS上传功能,支持直传减少服务器带宽占用
- 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化
- 移除过时的OSS上传示例文件oss-upload.js
- 更新课程页面实现跳转第三方小程序功能
- 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑
- 优化图片上传流程,增加压缩处理和批量上传支持
- 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存
- 添加后端环境变量配置模板和代理文档说明
2026-10-10 10:27:00 +08:00

240 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
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
class PasswordLoginRequest(BaseModel):
phone: str
password: str
class SetPasswordRequest(BaseModel):
"""运营更改单个学员登录密码的入参(需验证老密码)。
- `old_password`:老密码(`password_hash` 为空时默认 = 手机号后6位)。
- `new_password`:新密码。
"""
phone: str
old_password: str
new_password: str
# 密码长度约束(设置密码时校验;登录时不校验,避免历史弱密码登不进来)
PASSWORD_MIN_LEN = 8
PASSWORD_MAX_LEN = 64
def get_current_user(request: Request):
token = ""
# 1. 优先从 Authorization header 取(小程序 API 调用方式)
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
# 2. header 没有则从 cookie 取(webview H5 页面内请求方式)
if not token:
token = request.cookies.get("auth_token", "")
if not token:
raise HTTPException(status_code=401, detail="缺少 token")
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 = security.create_session_token()
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.post("/password-login")
def password_login(req: PasswordLoginRequest):
"""手机号 + 密码登录(与微信登录并列的第二条登录路径,2026-09-22 新增)。
- 查询靠 `phone_encrypted` 的 HMAC 比对,**库里不存明文手机号**,
做法与 `bind-phone` 完全一致(同一个 `encrypt_phone`)。
- 错误文案统一成「手机号或密码错误」,**刻意不区分**「账号不存在」与「密码不对」——
否则这个接口就成了「哪些手机号是有效学员」的枚举器。
- 响应结构与 `/wechat-login` 一致,前端两条登录路径可共用后续处理。
- **未设置密码(`password_hash` 为空)时,默认密码 = 手机号后6位**,便于学员免初始设置直接登录。
- ⚠️ 与微信登录保持一致,**不校验 `status`**:`暂停` 学员仍然能登录。
"""
phone = (req.phone or "").strip()
password = req.password or ""
if not phone or not password:
raise HTTPException(status_code=400, detail="手机号与密码均不能为空")
user = db.fetch_one(
"SELECT * FROM auth_users WHERE phone_encrypted=%s",
(security.encrypt_phone(phone),),
)
if not user:
logger.info("密码登录失败:手机号未匹配到账号")
raise HTTPException(status_code=401, detail="手机号或密码错误")
stored = user.get("password_hash")
used_default = False
if not stored:
# 未设置密码的账号,默认密码 = 手机号后6位(便于学员免初始设置直接登录)。
stored = security.hash_password(phone[-6:])
used_default = True
if not security.verify_password(password, stored):
# 日志里区分「默认密码也不对」与「密码错」,方便运营排查;对外文案保持一致。
# 典型情况:006 迁移还没执行 → password_hash 列取不到 → 这里走默认密码分支。
reason = "默认密码(手机号后6位)不正确" if used_default else "密码不正确"
logger.info("密码登录失败:user_id=%s %s", user["id"], reason)
raise HTTPException(status_code=401, detail="手机号或密码错误")
token = _record_session(user["id"])
return {"success": True, "token": token, **user_payload(user)}
def _check_password_input(phone, password):
"""校验一条待设置的账号密码,返回错误原因;空串表示通过。"""
if not phone:
return "手机号不能为空"
if not password:
return "密码不能为空"
if len(password) < PASSWORD_MIN_LEN or len(password) > PASSWORD_MAX_LEN:
return "密码长度需为 %d~%d 位" % (PASSWORD_MIN_LEN, PASSWORD_MAX_LEN)
if password == phone:
return "密码不能与手机号相同"
return ""
@router.post("/set-password")
def set_password(req: SetPasswordRequest):
"""更改单个学员登录密码(2026-09-22 新增,无运营密钥鉴权,靠老密码验证)。
- **改密码需先验证老密码**:`old_password` 正确才更新;`password_hash` 为空时
默认老密码 = 手机号后6位(与 `password-login` 的默认密码逻辑一致)。
- **直接覆盖已有密码**("重置"就是这个语义),没有二次确认。
- 手机号必须能匹配到 `auth_users`(用 `encrypt_phone` 比对);
匹配不到即报错,**不会新建账号** —— 本系统没有注册流程,用户由运营导入。
"""
phone = (req.phone or "").strip()
old_password = req.old_password or ""
new_password = req.new_password or ""
err = _check_password_input(phone, new_password)
if err:
raise HTTPException(status_code=400, detail=err)
user = db.fetch_one(
"SELECT * FROM auth_users WHERE phone_encrypted=%s",
(security.encrypt_phone(phone),),
)
if not user:
raise HTTPException(status_code=404, detail="手机号未匹配到学员账号")
# 验证老密码:password_hash 为空时,默认老密码 = 手机号后6位。
stored = user.get("password_hash")
if not stored:
stored = security.hash_password(phone[-6:])
if not security.verify_password(old_password, stored):
raise HTTPException(status_code=403, detail="老密码不正确")
db.execute(
"UPDATE auth_users SET password_hash=%s WHERE id=%s",
(security.hash_password(new_password), user["id"]),
)
logger.info("设置登录密码:user_id=%s", user["id"])
return {"success": True, "user_id": user["id"]}
@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)):
from fastapi.responses import JSONResponse
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"],),
)
resp = JSONResponse(content={"success": True})
# 同时清除 webview cookie 登录态
resp.delete_cookie(key="auth_token", path="/")
return resp