From 81972f1328a02825225562afe2cdd122f98bba54 Mon Sep 17 00:00:00 2001 From: jayhgq Date: Sun, 14 Jun 2026 22:19:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=B7=BB=E5=8A=A0=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E7=9F=AD=E4=BF=A1=E5=92=8C=E9=82=AE=E7=AE=B1=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交完成了用户认证模块的注册扩展: 1. 新增阿里云短信和异步邮件发送工具类 2. 新增短信/邮箱相关配置类与环境变量支持 3. 添加短信验证码、待注册数据的Redis缓存工具方法 4. 扩展用户登录逻辑,支持用户名/邮箱/手机号多方式登录 5. 实现手机短信注册和邮箱验证注册完整流程 6. 更新权限初始化数据与系统配置项 7. 补充相关Pydantic请求响应模型 8. 新增依赖包并完善requirements.txt --- backend/auth/apps/users/urls.py | 228 ++++++++++++++++++++++++++++- backend/auth/config.py | 29 ++++ backend/auth/init_data.py | 49 +++++-- backend/auth/requirements.txt | 8 +- backend/auth/schemas.py | 51 ++++++- backend/auth/utils/email.py | 71 +++++++++ backend/auth/utils/redis_client.py | 42 +++++- backend/auth/utils/sms.py | 40 +++++ 8 files changed, 490 insertions(+), 28 deletions(-) create mode 100644 backend/auth/utils/email.py create mode 100644 backend/auth/utils/sms.py diff --git a/backend/auth/apps/users/urls.py b/backend/auth/apps/users/urls.py index 13cfc1b..dee7ab4 100644 --- a/backend/auth/apps/users/urls.py +++ b/backend/auth/apps/users/urls.py @@ -1,13 +1,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status, Request -from fastapi.responses import Response +from fastapi.responses import Response, HTMLResponse from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, or_ from fastcrud import FastCRUD from datetime import timedelta, datetime +from uuid import uuid4 +import json import redis.asyncio as redis from database import get_db -from models import User +from models import User, SystemSetting from schemas import ( UserCreate, UserResponse, @@ -15,15 +17,27 @@ from schemas import ( Token, UserCreateRequest, UserUpdate, + PhoneSendCodeRequest, + PhoneSendCodeResponse, + PhoneRegisterRequest, + EmailRegisterInitRequest, + EmailRegisterInitResponse, + RegisterSuccessResponse, ) from utils.password import verify_password, get_password_hash -from utils.jwt import create_access_token +from utils.jwt import create_access_token, decode_token from config import app_settings -from utils.redis_client import get_redis, set_token_in_redis, delete_token_from_redis +from utils.redis_client import ( + get_redis, set_token_in_redis, delete_token_from_redis, + save_sms_code, get_and_delete_sms_code, + save_pending_registration, get_and_delete_pending_registration, +) from utils.captcha import create_captcha, validate_captcha from middleware import get_current_user from utils.like_filter import ilike_contains from utils.log_utils import get_client_ip, get_user_agent, save_login_log, create_operation_log +from utils.sms import generate_sms_code, send_sms_code +from utils.email import send_verification_email router = APIRouter(prefix="/users", tags=["users"]) @@ -67,13 +81,22 @@ async def login( headers={"WWW-Authenticate": "Bearer"}, ) - # 查找用户 - result = await db.execute(select(User).filter(User.username == user_data.username)) + # 查找用户(支持用户名/邮箱/手机号) + login_id = user_data.username + result = await db.execute( + select(User).filter( + or_( + User.username == login_id, + User.email == login_id, + User.phone == login_id, + ) + ) + ) user = result.scalar_one_or_none() # 验证用户是否存在且密码正确 if not user or not verify_password(user_data.password, user.password_hash): - await save_login_log(db, user_data.username, ip, ua, "failure", "用户名或密码错误") + await save_login_log(db, login_id, ip, ua, "failure", "用户名或密码错误") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误", @@ -306,6 +329,195 @@ async def delete_user( return {"message": "用户删除成功"} +# ======================================== +# 用户注册 +# ======================================== + + +@router.post("/register/send-code", response_model=PhoneSendCodeResponse) +async def send_register_sms_code( + req: PhoneSendCodeRequest, + db: AsyncSession = Depends(get_db), + redis_conn: redis.Redis = Depends(get_redis), +): + """发送手机注册验证码""" + # 检查短信注册开关 + result = await db.execute(select(SystemSetting).filter(SystemSetting.key == "启用手机短信注册")) + sms_setting = result.scalar_one_or_none() + if not sms_setting or sms_setting.value != "true": + raise HTTPException(status_code=403, detail="手机短信注册功能未启用") + + # 检查手机号是否已被注册 + result = await db.execute(select(User).filter(User.phone == req.phone)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="该手机号已被注册") + + # 频率限制:60秒内不能重复发送 + ttl = await redis_conn.ttl(f"sms_code:{req.phone}") + if ttl > 240: + raise HTTPException(status_code=429, detail="验证码已发送,请60秒后再试") + + # 生成验证码并存入 Redis + code = generate_sms_code() + await save_sms_code(redis_conn, req.phone, code) + + # 发送短信 + await send_sms_code(req.phone, code) + + return PhoneSendCodeResponse() + + +@router.post("/register/phone", response_model=RegisterSuccessResponse) +async def register_by_phone( + req: PhoneRegisterRequest, + db: AsyncSession = Depends(get_db), + redis_conn: redis.Redis = Depends(get_redis), +): + """通过手机号注册""" + # 检查短信注册开关 + result = await db.execute(select(SystemSetting).filter(SystemSetting.key == "启用手机短信注册")) + sms_setting = result.scalar_one_or_none() + if not sms_setting or sms_setting.value != "true": + raise HTTPException(status_code=403, detail="手机短信注册功能未启用") + + # 检查用户名是否已存在 + result = await db.execute(select(User).filter(User.username == req.username)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="用户名已存在") + + # 检查手机号是否已被注册 + result = await db.execute(select(User).filter(User.phone == req.phone)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="该手机号已被注册") + + # 验证短信验证码 + stored_code = await get_and_delete_sms_code(redis_conn, req.phone) + if stored_code is None: + raise HTTPException(status_code=400, detail="验证码已过期,请重新发送") + if stored_code != req.code: + raise HTTPException(status_code=400, detail="验证码错误") + + # 创建用户 + hashed_pw = get_password_hash(req.password) + new_user = User( + username=req.username, + nickname=req.nickname, + phone=req.phone, + password_hash=hashed_pw, + role_id=2, + ) + db.add(new_user) + await db.commit() + await db.refresh(new_user) + + return RegisterSuccessResponse( + id=new_user.id, username=new_user.username, nickname=new_user.nickname + ) + + +@router.post("/register/email/init", response_model=EmailRegisterInitResponse) +async def init_email_registration( + req: EmailRegisterInitRequest, + db: AsyncSession = Depends(get_db), + redis_conn: redis.Redis = Depends(get_redis), +): + """初始化邮箱注册(发送验证邮件)""" + # 检查邮箱注册开关 + result = await db.execute(select(SystemSetting).filter(SystemSetting.key == "启用邮箱注册")) + email_setting = result.scalar_one_or_none() + if not email_setting or email_setting.value != "true": + raise HTTPException(status_code=403, detail="邮箱注册功能未启用") + + # 检查用户名是否已存在 + result = await db.execute(select(User).filter(User.username == req.username)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="用户名已存在") + + # 检查邮箱是否已被注册 + result = await db.execute(select(User).filter(User.email == req.email)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="该邮箱已被注册") + + # 哈希密码后存储待注册数据 + hashed_pw = get_password_hash(req.password) + token_id = str(uuid4()) + pending_data = json.dumps({ + "username": req.username, + "nickname": req.nickname, + "email": req.email, + "password_hash": hashed_pw, + }) + await save_pending_registration(redis_conn, token_id, pending_data) + + # 生成 JWT 验证令牌 + jwt_token = create_access_token( + data={"sub": token_id, "type": "email_verify", "email": req.email}, + expires_delta=timedelta(minutes=30), + ) + + # 发送验证邮件 + await send_verification_email(req.email, jwt_token) + + return EmailRegisterInitResponse() + + +@router.get("/verify-email") +async def verify_email( + token: str = Query(..., description="验证令牌"), + db: AsyncSession = Depends(get_db), + redis_conn: redis.Redis = Depends(get_redis), +): + """验证邮箱并完成注册""" + # 检查邮箱注册开关 + result = await db.execute(select(SystemSetting).filter(SystemSetting.key == "启用邮箱注册")) + email_setting = result.scalar_one_or_none() + if not email_setting or email_setting.value != "true": + return HTMLResponse("

邮箱注册功能未启用

", status_code=403) + + # 解析 JWT + payload = decode_token(token) + if payload is None: + return HTMLResponse("

验证链接无效或已过期

", status_code=400) + if payload.get("type") != "email_verify": + return HTMLResponse("

无效的验证链接

", status_code=400) + + token_id = payload.get("sub") + pending_json = await get_and_delete_pending_registration(redis_conn, token_id) + if pending_json is None: + return HTMLResponse("

验证链接已过期或已使用

", status_code=400) + + data = json.loads(pending_json) + + # 再次检查用户名和邮箱唯一性 + result = await db.execute(select(User).filter(User.username == data["username"])) + if result.scalar_one_or_none(): + return HTMLResponse("

该用户名已被注册

", status_code=400) + + result = await db.execute(select(User).filter(User.email == data["email"])) + if result.scalar_one_or_none(): + return HTMLResponse("

该邮箱已被注册

", status_code=400) + + # 创建用户 + new_user = User( + username=data["username"], + nickname=data["nickname"], + email=data["email"], + password_hash=data["password_hash"], + role_id=2, + ) + db.add(new_user) + await db.commit() + await db.refresh(new_user) + + return HTMLResponse(f""" + + +

✅ 注册成功

+

欢迎 {new_user.username},您的账号已创建完成。

+

请返回应用使用 用户名、邮箱或手机号 登录。

+""") + + @router.post("/logout") async def logout( request: Request, diff --git a/backend/auth/config.py b/backend/auth/config.py index 5b02e4f..8ea21eb 100644 --- a/backend/auth/config.py +++ b/backend/auth/config.py @@ -57,7 +57,36 @@ class AppSettings(BaseSettings): env_prefix = "APP_" +class AliyunSMSSettings(BaseSettings): + """阿里云短信配置""" + + access_key: str = "" + access_secret: str = "" + sign_name: str = "" + template_code: str = "" + + class Config: + env_prefix = "ALIYUN_SMS_" + + +class EmailSettings(BaseSettings): + """邮件发送配置""" + + smtp_host: str = "" + smtp_port: int = 465 + smtp_user: str = "" + smtp_password: str = "" + use_tls: bool = True + from_addr: str = "" + base_url: str = "http://localhost:8000" + + class Config: + env_prefix = "EMAIL_" + + # 创建设置实例 postgres_settings = PostgreSQLSettings() redis_settings = RedisSettings() app_settings = AppSettings() +aliyun_sms_settings = AliyunSMSSettings() +email_settings = EmailSettings() diff --git a/backend/auth/init_data.py b/backend/auth/init_data.py index 76dc928..8d66f90 100644 --- a/backend/auth/init_data.py +++ b/backend/auth/init_data.py @@ -10,21 +10,21 @@ async def init_permissions(db: AsyncSession): # parentid: 0 = 顶级模块, >0 = 子权限 permission_tree = [ # ======================================== - # 顶级模块 + # 顶级模块 (DB ID: 1,2,3) # ======================================== {"name": "权限管理", "code": "perm", "parentid": 0, "route": "/admin/permissions", "description": "权限管理模块"}, {"name": "系统设置", "code": "system", "parentid": 0, "route": "/admin/settings", "description": "系统设置模块"}, {"name": "日志管理", "code": "log", "parentid": 0, "route": "/admin/logs", "description": "日志管理模块"}, # ======================================== - # 权限管理 -> 二级子模块 + # 权限管理 -> 二级子模块 (parentid=1, DB ID: 4,5,6) # ======================================== {"name": "用户管理", "code": "perm:user", "parentid": 1, "route": "/admin/users", "description": "用户管理子模块"}, {"name": "角色管理", "code": "perm:role", "parentid": 1, "route": "/admin/roles", "description": "角色管理子模块"}, {"name": "菜单管理", "code": "perm:menu", "parentid": 1, "route": "/admin/menu", "description": "菜单管理子模块"}, - + # ======================================== - # 用户管理操作(parentid=4) + # 用户管理操作 (parentid=4, DB ID: 7-14) # ======================================== {"name": "查看列表", "code": "perm:user:list", "parentid": 4, "description": "查看用户列表"}, {"name": "查看详情", "code": "perm:user:detail", "parentid": 4, "description": "查看用户详情"}, @@ -34,9 +34,9 @@ async def init_permissions(db: AsyncSession): {"name": "删除", "code": "perm:user:delete", "parentid": 4, "description": "删除用户"}, {"name": "导出", "code": "perm:user:export", "parentid": 4, "description": "导出用户数据"}, {"name": "批量操作", "code": "perm:user:batch", "parentid": 4, "description": "批量操作用户"}, - + # ======================================== - # 角色管理操作(parentid=5) + # 角色管理操作 (parentid=5, DB ID: 15-22) # ======================================== {"name": "查看列表", "code": "perm:role:list", "parentid": 5, "description": "查看角色列表"}, {"name": "查看详情", "code": "perm:role:detail", "parentid": 5, "description": "查看角色详情"}, @@ -46,9 +46,9 @@ async def init_permissions(db: AsyncSession): {"name": "删除", "code": "perm:role:delete", "parentid": 5, "description": "删除角色"}, {"name": "导出", "code": "perm:role:export", "parentid": 5, "description": "导出角色数据"}, {"name": "分配权限", "code": "perm:role:assign", "parentid": 5, "description": "为角色分配权限"}, - + # ======================================== - # 菜单管理操作(parentid=6) + # 菜单管理操作 (parentid=6, DB ID: 23-29) # ======================================== {"name": "查看列表", "code": "perm:menu:list", "parentid": 6, "description": "查看菜单列表"}, {"name": "查看详情", "code": "perm:menu:detail", "parentid": 6, "description": "查看菜单详情"}, @@ -59,25 +59,30 @@ async def init_permissions(db: AsyncSession): {"name": "导出", "code": "perm:menu:export", "parentid": 6, "description": "导出菜单数据"}, # ======================================== - # 日志管理 -> 子模块(parentid=3) + # 系统设置 -> 子权限 (parentid=2, DB ID: 30) + # ======================================== + {"name": "系统设置", "code": "system:view", "parentid": 2, "description": "查看和修改系统设置"}, + + # ======================================== + # 日志管理 -> 子模块 (parentid=3, DB ID: 31,32) # ======================================== {"name": "登录日志", "code": "log:login", "parentid": 3, "route": "/admin/login-logs", "description": "登录日志查看"}, {"name": "操作日志", "code": "log:operation", "parentid": 3, "route": "/admin/operation-logs", "description": "操作日志查看"}, # ======================================== - # 登录日志操作(parentid=30) + # 登录日志操作 (parentid=31, DB ID: 33,34) # ======================================== - {"name": "查看列表", "code": "log:login:list", "parentid": 30, "description": "查看登录日志列表"}, - {"name": "搜索", "code": "log:login:search", "parentid": 30, "description": "搜索登录日志"}, + {"name": "查看列表", "code": "log:login:list", "parentid": 31, "description": "查看登录日志列表"}, + {"name": "搜索", "code": "log:login:search", "parentid": 31, "description": "搜索登录日志"}, # ======================================== - # 操作日志操作(parentid=31) + # 操作日志操作 (parentid=32, DB ID: 35,36) # ======================================== - {"name": "查看列表", "code": "log:operation:list", "parentid": 31, "description": "查看操作日志列表"}, - {"name": "搜索", "code": "log:operation:search", "parentid": 31, "description": "搜索操作日志"}, + {"name": "查看列表", "code": "log:operation:list", "parentid": 32, "description": "查看操作日志列表"}, + {"name": "搜索", "code": "log:operation:search", "parentid": 32, "description": "搜索操作日志"}, # ======================================== - # 日志清理(parentid=3) + # 日志清理 (parentid=3, DB ID: 37) # ======================================== {"name": "清理日志", "code": "log:clean", "parentid": 3, "description": "清理旧日志"}, ] @@ -372,6 +377,18 @@ async def init_system_settings(db: AsyncSession): "value": "京ICP备2023000000号", "type": "string", "description": "ICP备案信息" + }, + { + "key": "启用手机短信注册", + "value": "false", + "type": "boolean", + "description": "是否允许通过手机短信验证码注册" + }, + { + "key": "启用邮箱注册", + "value": "false", + "type": "boolean", + "description": "是否允许通过邮箱验证链接注册" } ] diff --git a/backend/auth/requirements.txt b/backend/auth/requirements.txt index 46eaa49..b26b5c3 100644 --- a/backend/auth/requirements.txt +++ b/backend/auth/requirements.txt @@ -31,4 +31,10 @@ redis>=7.2.0 captcha>=0.7.1 # 文件上传 -python-multipart>=0.0.9 \ No newline at end of file +python-multipart>=0.0.9 + +# 阿里云短信SDK +aliyun-python-sdk-core>=2.15.0 + +# 异步邮件发送 +aiosmtplib>=2.0.2 \ No newline at end of file diff --git a/backend/auth/schemas.py b/backend/auth/schemas.py index 3185148..f94a2f7 100644 --- a/backend/auth/schemas.py +++ b/backend/auth/schemas.py @@ -69,7 +69,7 @@ class UserUpdate(BaseModel): class UserLogin(BaseModel): """用户登录模型""" - username: str = Field(..., description="登录名") + username: str = Field(..., description="用户名/邮箱/手机号") password: str = Field(..., description="密码") captcha_id: str = Field(..., description="验证码ID") captcha_code: str = Field(..., description="验证码") @@ -285,4 +285,51 @@ class OperationLogSearch(BaseModel): action_type: Optional[str] = Field(None, description="操作类型") target_type: Optional[str] = Field(None, description="目标类型") start_time: Optional[datetime] = Field(None, description="开始时间") - end_time: Optional[datetime] = Field(None, description="结束时间") \ No newline at end of file + end_time: Optional[datetime] = Field(None, description="结束时间") + + +# ======================================== +# 用户注册相关模型 +# ======================================== + + +class PhoneSendCodeRequest(BaseModel): + """发送短信验证码请求""" + phone: str = Field(..., pattern=r"^\d{11}$", description="手机号(11位数字)") + + +class PhoneSendCodeResponse(BaseModel): + """发送短信验证码响应""" + message: str = "验证码已发送" + expire_seconds: int = 300 + + +class PhoneRegisterRequest(BaseModel): + """手机号注册请求""" + username: str = Field(..., min_length=3, max_length=50, description="用户名") + phone: str = Field(..., pattern=r"^\d{11}$", description="手机号") + code: str = Field(..., min_length=6, max_length=6, description="短信验证码") + password: str = Field(..., min_length=6, max_length=50, description="密码") + nickname: str = Field(..., max_length=50, description="昵称") + + +class EmailRegisterInitRequest(BaseModel): + """邮箱注册初始化请求""" + username: str = Field(..., min_length=3, max_length=50, description="用户名") + email: str = Field(..., max_length=50, description="电子邮箱") + password: str = Field(..., min_length=6, max_length=50, description="密码") + nickname: str = Field(..., max_length=50, description="昵称") + + +class EmailRegisterInitResponse(BaseModel): + """邮箱注册初始化响应""" + message: str = "验证邮件已发送,请查收并点击链接完成注册" + expire_minutes: int = 30 + + +class RegisterSuccessResponse(BaseModel): + """注册成功响应""" + id: int + username: str + nickname: str + message: str = "注册成功" \ No newline at end of file diff --git a/backend/auth/utils/email.py b/backend/auth/utils/email.py new file mode 100644 index 0000000..6d34649 --- /dev/null +++ b/backend/auth/utils/email.py @@ -0,0 +1,71 @@ +from fastapi import HTTPException +from config import email_settings +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + + +def _build_verification_email(token: str) -> str: + """构建验证邮件HTML内容""" + verify_url = f"{email_settings.base_url.rstrip('/')}/verify-email?token={token}" + return f""" + + + +

📧 邮箱验证

+

感谢注册!请点击下方按钮完成邮箱验证:

+

+ + 点击验证邮箱 + +

+

或者复制以下链接到浏览器打开:

+

{verify_url}

+

链接有效期为30分钟,请尽快完成验证。

+ +""" + + +async def send_verification_email(to_email: str, token: str) -> bool: + """发送邮箱验证链接""" + try: + import aiosmtplib + + msg = MIMEMultipart("alternative") + msg["Subject"] = "邮箱验证 - 图书管理系统" + msg["From"] = email_settings.from_addr + msg["To"] = to_email + msg.attach(MIMEText(_build_verification_email(token), "html", "utf-8")) + + await aiosmtplib.send( + msg, + hostname=email_settings.smtp_host, + port=email_settings.smtp_port, + username=email_settings.smtp_user, + password=email_settings.smtp_password, + use_tls=email_settings.use_tls, + ) + return True + except ImportError: + # aiosmtplib 未安装时降级使用标准库 smtplib + import smtplib + + msg = MIMEMultipart("alternative") + msg["Subject"] = "邮箱验证 - 图书管理系统" + msg["From"] = email_settings.from_addr + msg["To"] = to_email + msg.attach(MIMEText(_build_verification_email(token), "html", "utf-8")) + + if email_settings.use_tls: + server = smtplib.SMTP_SSL(email_settings.smtp_host, email_settings.smtp_port) + else: + server = smtplib.SMTP(email_settings.smtp_host, email_settings.smtp_port) + server.login(email_settings.smtp_user, email_settings.smtp_password) + server.sendmail(email_settings.from_addr, to_email, msg.as_string()) + server.quit() + return True + except Exception as e: + raise HTTPException( + status_code=500, + detail="邮件发送失败,请稍后重试", + ) diff --git a/backend/auth/utils/redis_client.py b/backend/auth/utils/redis_client.py index a687fd3..caf7c94 100644 --- a/backend/auth/utils/redis_client.py +++ b/backend/auth/utils/redis_client.py @@ -38,4 +38,44 @@ async def delete_token_from_redis(redis_conn, user_id: int): async def check_token_in_redis(redis_conn, user_id: int, token: str): """检查令牌是否在Redis中且有效""" stored_token = await get_token_from_redis(redis_conn, user_id) - return stored_token == token \ No newline at end of file + return stored_token == token + + +# ======================================== +# 注册相关:短信验证码 +# ======================================== + + +async def save_sms_code(redis_conn, phone: str, code: str, expire_seconds: int = 300): + """存储短信验证码""" + key = f"sms_code:{phone}" + await redis_conn.set(key, code, ex=expire_seconds) + + +async def get_and_delete_sms_code(redis_conn, phone: str) -> str | None: + """获取并删除短信验证码(一次性使用)""" + key = f"sms_code:{phone}" + code = await redis_conn.get(key) + if code: + await redis_conn.delete(key) + return code + + +# ======================================== +# 注册相关:待注册数据(邮箱验证) +# ======================================== + + +async def save_pending_registration(redis_conn, token_id: str, data: str, expire_seconds: int = 1800): + """存储待注册数据""" + key = f"reg_pending:{token_id}" + await redis_conn.set(key, data, ex=expire_seconds) + + +async def get_and_delete_pending_registration(redis_conn, token_id: str) -> str | None: + """获取并删除待注册数据(一次性使用)""" + key = f"reg_pending:{token_id}" + data = await redis_conn.get(key) + if data: + await redis_conn.delete(key) + return data \ No newline at end of file diff --git a/backend/auth/utils/sms.py b/backend/auth/utils/sms.py new file mode 100644 index 0000000..f389b1c --- /dev/null +++ b/backend/auth/utils/sms.py @@ -0,0 +1,40 @@ +import random +from fastapi import HTTPException +from config import aliyun_sms_settings + + +def generate_sms_code() -> str: + """生成6位数字验证码""" + return str(random.randint(100000, 999999)) + + +async def send_sms_code(phone: str, code: str) -> bool: + """通过阿里云发送短信验证码""" + try: + from aliyunsdkcore.client import AcsClient + from aliyunsdkcore.request import CommonRequest + + client = AcsClient( + aliyun_sms_settings.access_key, + aliyun_sms_settings.access_secret, + "cn-hangzhou", + ) + + request = CommonRequest() + request.set_accept_format("json") + request.set_domain("dysmsapi.aliyuncs.com") + request.set_method("POST") + request.set_version("2017-05-25") + request.set_action_name("SendSms") + request.add_query_param("PhoneNumbers", phone) + request.add_query_param("SignName", aliyun_sms_settings.sign_name) + request.add_query_param("TemplateCode", aliyun_sms_settings.template_code) + request.add_query_param("TemplateParam", f'{{"code":"{code}"}}') + + response = client.do_action_with_exception(request) + return True + except Exception as e: + raise HTTPException( + status_code=500, + detail="短信发送失败,请稍后重试", + )