- 替换开发环境API基础URL为本地服务地址 - 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题 - 添加缓存清理功能,包括过期清理和按需删除特定缓存项 - 集成阿里云OSS上传功能,支持直传减少服务器带宽占用 - 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化 - 移除过时的OSS上传示例文件oss-upload.js - 更新课程页面实现跳转第三方小程序功能 - 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑 - 优化图片上传流程,增加压缩处理和批量上传支持 - 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存 - 添加后端环境变量配置模板和代理文档说明
239 lines
8.9 KiB
Python
239 lines
8.9 KiB
Python
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, request: Request):
|
||
"""
|
||
- **改密码需先验证老密码**:`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
|