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