service.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750
  1. import json
  2. import uuid
  3. from datetime import datetime, timedelta
  4. from typing import NewType
  5. from fastapi import Request
  6. from redis.asyncio.client import Redis
  7. from sqlalchemy.ext.asyncio import AsyncSession
  8. from user_agents import parse
  9. from app.api.v1.module_monitor.online.schema import OnlineOutSchema
  10. from app.api.v1.module_system.user.crud import UserCRUD
  11. from app.api.v1.module_system.user.model import UserModel
  12. from app.common.enums import RedisInitKeyConfig
  13. from app.config.setting import settings
  14. from app.core.exceptions import CustomException
  15. from app.core.logger import log
  16. from app.core.redis_crud import RedisCURD
  17. from app.core.security import (
  18. CustomOAuth2PasswordRequestForm,
  19. create_access_token,
  20. decode_access_token,
  21. )
  22. from app.utils.captcha_util import CaptchaUtil
  23. from app.utils.common_util import get_random_character, generate_random_code
  24. from app.utils.hash_bcrpy_util import PwdUtil
  25. from app.utils.ip_local_util import IpLocalUtil
  26. from app.utils.sms_util import SendSmsRequest, SmsTemplateEnum, SmsSender
  27. from .schema import (
  28. AuthSchema,
  29. AutoLoginTokenSchema,
  30. AutoLoginUserSchema,
  31. CaptchaOutSchema,
  32. JWTOutSchema,
  33. JWTPayloadSchema,
  34. LoginMiniRequestSchema,
  35. LogoutPayloadSchema,
  36. RefreshTokenPayloadSchema,
  37. SmsCodeSchema,
  38. SmsLoginRequestSchema,
  39. )
  40. CaptchaKey = NewType("CaptchaKey", str)
  41. CaptchaBase64 = NewType("CaptchaBase64", str)
  42. class LoginService:
  43. """登录认证服务"""
  44. @classmethod
  45. async def authenticate_mini_user_service(
  46. cls,
  47. request: Request,
  48. redis: Redis,
  49. login_form: LoginMiniRequestSchema,
  50. db: AsyncSession,
  51. ) -> JWTOutSchema:
  52. # 小程序用户认证
  53. auth = AuthSchema(db=db)
  54. user = await UserCRUD(auth).get_by_username_crud(username=login_form.username)
  55. if not user:
  56. raise CustomException(msg="用户不存在")
  57. if not PwdUtil.verify_password(
  58. plain_password=login_form.password, password_hash=user.password
  59. ):
  60. raise CustomException(msg="账号或密码错误")
  61. if user.status == "1":
  62. raise CustomException(msg="用户已被停用")
  63. # 更新最后登录时间
  64. user = await UserCRUD(auth).update_last_login_crud(id=user.id)
  65. if not user:
  66. raise CustomException(msg="用户不存在")
  67. if not login_form.login_type:
  68. raise CustomException(msg="登录类型不能为空")
  69. # 创建token
  70. token = await cls.create_token_service(
  71. request=request,
  72. redis=redis,
  73. user=user,
  74. login_type=login_form.login_type,
  75. )
  76. return token
  77. @classmethod
  78. async def authenticate_user_service(
  79. cls,
  80. request: Request,
  81. redis: Redis,
  82. login_form: CustomOAuth2PasswordRequestForm,
  83. db: AsyncSession,
  84. ) -> JWTOutSchema:
  85. """
  86. 用户认证
  87. 参数:
  88. - request (Request): FastAPI请求对象
  89. - login_form (CustomOAuth2PasswordRequestForm): 登录表单数据
  90. - db (AsyncSession): 数据库会话对象
  91. 返回:
  92. - JWTOutSchema: 包含访问令牌和刷新令牌的响应模型
  93. 异常:
  94. - CustomException: 认证失败时抛出异常。
  95. """
  96. # 判断是否来自API文档
  97. referer = request.headers.get("referer", "")
  98. request_from_docs = referer.endswith(("docs", "redoc"))
  99. # 验证码校验
  100. if settings.CAPTCHA_ENABLE and not request_from_docs:
  101. if not login_form.captcha_key or not login_form.captcha:
  102. raise CustomException(msg="验证码不能为空")
  103. await CaptchaService.check_captcha_service(
  104. redis=redis,
  105. key=login_form.captcha_key,
  106. captcha=login_form.captcha,
  107. )
  108. # 用户认证
  109. auth = AuthSchema(db=db)
  110. user = await UserCRUD(auth).get_by_username_crud(username=login_form.username)
  111. if not user:
  112. raise CustomException(msg="用户不存在")
  113. if not PwdUtil.verify_password(
  114. plain_password=login_form.password, password_hash=user.password
  115. ):
  116. raise CustomException(msg="账号或密码错误")
  117. if user.status == "1":
  118. raise CustomException(msg="用户已被停用")
  119. # 更新最后登录时间
  120. user = await UserCRUD(auth).update_last_login_crud(id=user.id)
  121. if not user:
  122. raise CustomException(msg="用户不存在")
  123. if not login_form.login_type:
  124. raise CustomException(msg="登录类型不能为空")
  125. # 创建token
  126. token = await cls.create_token_service(
  127. request=request,
  128. redis=redis,
  129. user=user,
  130. login_type=login_form.login_type,
  131. )
  132. return token
  133. @classmethod
  134. async def authenticate_sms_user_service(
  135. cls,
  136. request: Request,
  137. redis: Redis,
  138. login_form: SmsLoginRequestSchema,
  139. db: AsyncSession,
  140. ) -> JWTOutSchema:
  141. """
  142. 短信验证码登录
  143. 参数:
  144. - request (Request): FastAPI请求对象
  145. - redis (Redis): Redis客户端对象
  146. - login_form (SmsLoginRequestSchema): 短信登录表单数据
  147. - db (AsyncSession): 数据库会话对象
  148. 返回:
  149. - JWTOutSchema: 包含访问令牌和刷新令牌的响应模型
  150. 异常:
  151. - CustomException: 认证失败时抛出异常。
  152. """
  153. mobile = login_form.mobile
  154. code = login_form.code
  155. # 验证验证码
  156. # verify_result = await SmsCodeService.verify_sms_code_service(login_form, redis)
  157. # if not verify_result:
  158. # raise CustomException(msg="验证码已过期或错误")
  159. pass # 测试阶段跳过短信验证码校验
  160. # 根据手机号查找用户
  161. from app.api.v1.module_system.user.model import UserModel
  162. from sqlalchemy import select
  163. stmt = select(UserModel).where(UserModel.mobile == mobile)
  164. result = await db.execute(stmt)
  165. user = result.scalar_one_or_none()
  166. if not user:
  167. log.error(f"短信登录-未找到用户: mobile={mobile}")
  168. raise CustomException(msg="用户不存在")
  169. log.info(f"短信登录-找到用户: id={user.id}, mobile={user.mobile}, status={user.status}")
  170. if user.status == "1":
  171. raise CustomException(msg="用户已被停用")
  172. # 检查员工签约:无员工记录放行;有记录需已签约
  173. from app.plugin.module_payment.employee.model import EmployeeModel
  174. emp_stmt = select(EmployeeModel).where(EmployeeModel.user_id == user.id)
  175. emp_result = await db.execute(emp_stmt)
  176. employee = emp_result.scalar_one_or_none()
  177. if employee and employee.status != "EMPLOYEE_ACTIVATED":
  178. raise CustomException(msg="用户不存在")
  179. # 更新最后登录时间(记录日志即可,不阻塞登录)
  180. from datetime import datetime
  181. from sqlalchemy import update as sa_update
  182. try:
  183. await db.execute(
  184. sa_update(UserModel).where(UserModel.id == user.id).values(last_login=datetime.now())
  185. )
  186. except Exception as e:
  187. log.warning(f"短信登录-更新最后登录时间失败: {e}")
  188. # 创建token
  189. token = await cls.create_token_service(
  190. request=request,
  191. redis=redis,
  192. user=user,
  193. login_type="sms",
  194. )
  195. return token
  196. @classmethod
  197. async def create_token_service(
  198. cls, request: Request, redis: Redis, user: UserModel, login_type: str
  199. ) -> JWTOutSchema:
  200. """
  201. 创建访问令牌和刷新令牌
  202. 参数:
  203. - request (Request): FastAPI请求对象
  204. - redis (Redis): Redis客户端对象
  205. - user (UserModel): 用户模型对象
  206. - login_type (str): 登录类型
  207. 返回:
  208. - JWTOutSchema: 包含访问令牌和刷新令牌的响应模型
  209. 异常:
  210. - CustomException: 创建令牌失败时抛出异常。
  211. """
  212. # 生成会话编号
  213. session_id = str(uuid.uuid4())
  214. request.scope["session_id"] = session_id
  215. user_agent = parse(request.headers.get("user-agent"))
  216. request_ip = None
  217. x_forwarded_for = request.headers.get("X-Forwarded-For")
  218. if x_forwarded_for:
  219. # 取第一个 IP 地址,通常为客户端真实 IP
  220. request_ip = x_forwarded_for.split(",")[0].strip()
  221. else:
  222. # 若没有 X-Forwarded-For 头,则使用 request.client.host
  223. request_ip = request.client.host if request.client else "127.0.0.1"
  224. login_location = await IpLocalUtil.resolve_location_for_log(request_ip)
  225. request.scope["login_location"] = login_location
  226. # 确保在请求上下文中设置用户名和会话ID
  227. request.scope["user_username"] = user.username
  228. access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
  229. refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
  230. now = datetime.now()
  231. # 记录租户信息到日志
  232. log.info(f"用户ID: {user.id}, 用户名: {user.username} 正在生成JWT令牌")
  233. # 生成会话信息
  234. session_info = OnlineOutSchema(
  235. session_id=session_id,
  236. user_id=user.id,
  237. name=user.name,
  238. user_name=user.username,
  239. ipaddr=request_ip,
  240. login_location=login_location,
  241. os=user_agent.os.family,
  242. browser=user_agent.browser.family,
  243. login_time=user.last_login,
  244. login_type=login_type,
  245. ).model_dump_json()
  246. access_token = create_access_token(
  247. payload=JWTPayloadSchema(
  248. sub=session_info,
  249. is_refresh=False,
  250. exp=now + access_expires,
  251. )
  252. )
  253. refresh_token = create_access_token(
  254. payload=JWTPayloadSchema(
  255. sub=session_info,
  256. is_refresh=True,
  257. exp=now + refresh_expires,
  258. )
  259. )
  260. # 设置新的token
  261. await RedisCURD(redis).set(
  262. key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
  263. value=access_token,
  264. expire=int(access_expires.total_seconds()),
  265. )
  266. await RedisCURD(redis).set(
  267. key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
  268. value=refresh_token,
  269. expire=int(refresh_expires.total_seconds()),
  270. )
  271. return JWTOutSchema(
  272. access_token=access_token,
  273. refresh_token=refresh_token,
  274. expires_in=int(access_expires.total_seconds()),
  275. token_type=settings.TOKEN_TYPE,
  276. )
  277. @classmethod
  278. async def refresh_token_service(
  279. cls,
  280. db: AsyncSession,
  281. redis: Redis,
  282. request: Request,
  283. refresh_token: RefreshTokenPayloadSchema,
  284. ) -> JWTOutSchema:
  285. """
  286. 刷新访问令牌
  287. 参数:
  288. - db (AsyncSession): 数据库会话对象
  289. - redis (Redis): Redis客户端对象
  290. - request (Request): FastAPI请求对象
  291. - refresh_token (RefreshTokenPayloadSchema): 刷新令牌数据
  292. 返回:
  293. - JWTOutSchema: 新的令牌对象
  294. 异常:
  295. - CustomException: 刷新令牌无效时抛出异常
  296. """
  297. token_payload: JWTPayloadSchema = decode_access_token(token=refresh_token.refresh_token)
  298. if not token_payload.is_refresh:
  299. raise CustomException(msg="非法凭证,请传入刷新令牌")
  300. # 去 Redis 查完整信息
  301. session_info = json.loads(token_payload.sub)
  302. session_id = session_info.get("session_id")
  303. user_id = session_info.get("user_id")
  304. if not session_id or not user_id:
  305. raise CustomException(msg="非法凭证,无法获取会话编号或用户ID")
  306. # 用户认证
  307. auth = AuthSchema(db=db)
  308. user = await UserCRUD(auth).get_by_id_crud(id=user_id)
  309. if not user:
  310. raise CustomException(msg="刷新token失败,用户不存在")
  311. # 记录刷新令牌时的租户信息
  312. log.info(f"用户ID: {user.id}, 用户名: {user.username} 正在刷新JWT令牌")
  313. # 设置新的 token
  314. access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
  315. refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
  316. now = datetime.now()
  317. session_info_json = json.dumps(session_info)
  318. access_token = create_access_token(
  319. payload=JWTPayloadSchema(
  320. sub=session_info_json,
  321. is_refresh=False,
  322. exp=now + access_expires,
  323. )
  324. )
  325. refresh_token_new = create_access_token(
  326. payload=JWTPayloadSchema(
  327. sub=session_info_json,
  328. is_refresh=True,
  329. exp=now + refresh_expires,
  330. )
  331. )
  332. # 覆盖写入 Redis
  333. await RedisCURD(redis).set(
  334. key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
  335. value=access_token,
  336. expire=int(access_expires.total_seconds()),
  337. )
  338. await RedisCURD(redis).set(
  339. key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
  340. value=refresh_token_new,
  341. expire=int(refresh_expires.total_seconds()),
  342. )
  343. return JWTOutSchema(
  344. access_token=access_token,
  345. refresh_token=refresh_token_new,
  346. token_type=settings.TOKEN_TYPE,
  347. expires_in=int(access_expires.total_seconds()),
  348. )
  349. @classmethod
  350. async def logout_service(cls, redis: Redis, token: LogoutPayloadSchema) -> bool:
  351. """
  352. 退出登录
  353. 参数:
  354. - redis (Redis): Redis客户端对象
  355. - token (LogoutPayloadSchema): 退出登录令牌数据
  356. 返回:
  357. - bool: 退出成功返回True
  358. 异常:
  359. - CustomException: 令牌无效时抛出异常
  360. """
  361. payload: JWTPayloadSchema = decode_access_token(token=token.token)
  362. session_info = json.loads(payload.sub)
  363. session_id = session_info.get("session_id")
  364. if not session_id:
  365. raise CustomException(msg="非法凭证,无法获取会话编号")
  366. # 删除Redis中的在线用户、访问令牌、刷新令牌
  367. await RedisCURD(redis).delete(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}")
  368. await RedisCURD(redis).delete(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}")
  369. log.info(f"用户退出登录成功,会话编号:{session_id}")
  370. return True
  371. class CaptchaService:
  372. """验证码服务"""
  373. @classmethod
  374. async def get_captcha_service(cls, redis: Redis) -> dict[str, CaptchaKey | CaptchaBase64]:
  375. """
  376. 获取验证码
  377. 参数:
  378. - redis (Redis): Redis客户端对象
  379. 返回:
  380. - dict[str, CaptchaKey | CaptchaBase64]: 包含验证码key和base64图片的字典
  381. 异常:
  382. - CustomException: 验证码服务未启用时抛出异常
  383. """
  384. if not settings.CAPTCHA_ENABLE:
  385. raise CustomException(msg="未开启验证码服务")
  386. # 生成验证码图片和值
  387. captcha_base64, captcha_value = CaptchaUtil.captcha_arithmetic()
  388. captcha_key = get_random_character()
  389. # 保存到Redis并设置过期时间
  390. redis_key = f"{RedisInitKeyConfig.CAPTCHA_CODES.key}:{captcha_key}"
  391. await RedisCURD(redis).set(
  392. key=redis_key,
  393. value=captcha_value,
  394. expire=settings.CAPTCHA_EXPIRE_SECONDS,
  395. )
  396. log.info(f"生成验证码成功,验证码:{captcha_value}")
  397. # 返回验证码信息
  398. return CaptchaOutSchema(
  399. enable=settings.CAPTCHA_ENABLE,
  400. key=CaptchaKey(captcha_key),
  401. img_base=CaptchaBase64(f"data:image/png;base64,{captcha_base64}"),
  402. ).model_dump()
  403. @classmethod
  404. async def check_captcha_service(cls, redis: Redis, key: str, captcha: str) -> bool:
  405. """
  406. 校验验证码
  407. 参数:
  408. - redis (Redis): Redis客户端对象
  409. - key (str): 验证码key
  410. - captcha (str): 用户输入的验证码
  411. 返回:
  412. - bool: 验证通过返回True
  413. 异常:
  414. - CustomException: 验证码无效或错误时抛出异常
  415. """
  416. if not captcha:
  417. raise CustomException(msg="验证码不能为空")
  418. # 获取Redis中存储的验证码
  419. redis_key = f"{RedisInitKeyConfig.CAPTCHA_CODES.key}:{key}"
  420. captcha_value = await RedisCURD(redis).get(redis_key)
  421. if not captcha_value:
  422. log.error("验证码已过期或不存在")
  423. raise CustomException(msg="验证码已过期")
  424. # 验证码不区分大小写比对
  425. if captcha.lower() != captcha_value.lower():
  426. log.error(f"验证码错误,用户输入:{captcha},正确值:{captcha_value}")
  427. raise CustomException(msg="验证码错误")
  428. # 验证成功后删除验证码,避免重复使用
  429. await RedisCURD(redis).delete(redis_key)
  430. log.info(f"验证码校验成功,key:{key}")
  431. return True
  432. class AutoLoginService:
  433. """免登录服务"""
  434. # 免登录Token前缀
  435. AUTO_LOGIN_PREFIX = "fastapiadmin:auto_login:"
  436. # Token有效期(秒) - 5分钟
  437. TOKEN_EXPIRE = 300
  438. @classmethod
  439. async def get_auto_login_users_service(cls, db: AsyncSession) -> list[AutoLoginUserSchema]:
  440. """
  441. 获取免登录用户列表
  442. 参数:
  443. - db (AsyncSession): 数据库会话对象
  444. 返回:
  445. - list[AutoLoginUserSchema]: 用户列表
  446. """
  447. from sqlalchemy import select
  448. from app.api.v1.module_system.user.model import UserModel
  449. # 查询所有启用的用户
  450. stmt = select(UserModel).where(UserModel.status == "0").order_by(UserModel.id)
  451. result = await db.execute(stmt)
  452. users = result.scalars().all()
  453. return [
  454. AutoLoginUserSchema(
  455. id=user.id,
  456. username=user.username,
  457. name=user.name,
  458. avatar=user.avatar,
  459. )
  460. for user in users
  461. ]
  462. @classmethod
  463. async def create_auto_login_token_service(
  464. cls, redis: Redis, db: AsyncSession, user_id: int
  465. ) -> AutoLoginTokenSchema:
  466. """
  467. 创建免登录Token
  468. 参数:
  469. - request (Request): FastAPI请求对象
  470. - redis (Redis): Redis客户端对象
  471. - db (AsyncSession): 数据库会话对象
  472. - user_id (int): 用户ID
  473. 返回:
  474. - AutoLoginTokenSchema: 免登录Token和用户信息
  475. 异常:
  476. - CustomException: 用户不存在或已停用时抛出异常
  477. """
  478. from sqlalchemy import select
  479. from app.api.v1.module_system.user.model import UserModel
  480. # 查询用户
  481. stmt = select(UserModel).where(UserModel.id == user_id)
  482. result = await db.execute(stmt)
  483. user = result.scalar_one_or_none()
  484. if not user:
  485. raise CustomException(msg="用户不存在")
  486. if user.status == "1":
  487. raise CustomException(msg="用户已被停用")
  488. # 生成免登录Token
  489. import uuid
  490. token = str(uuid.uuid4())
  491. token_key = f"{cls.AUTO_LOGIN_PREFIX}{token}"
  492. # 存储到Redis,设置5分钟过期
  493. token_data = {
  494. "user_id": user.id,
  495. "username": user.username,
  496. "created_at": datetime.now().isoformat(),
  497. }
  498. await RedisCURD(redis).set(
  499. key=token_key,
  500. value=json.dumps(token_data),
  501. expire=cls.TOKEN_EXPIRE,
  502. )
  503. log.info(f"创建免登录Token成功,用户:{user.username}")
  504. return AutoLoginTokenSchema(
  505. token=token,
  506. user=AutoLoginUserSchema(
  507. id=user.id,
  508. username=user.username,
  509. name=user.name,
  510. avatar=user.avatar,
  511. ),
  512. )
  513. @classmethod
  514. async def auto_login_service(
  515. cls, request: Request, redis: Redis, db: AsyncSession, token: str
  516. ) -> JWTOutSchema:
  517. """
  518. 免登录
  519. 参数:
  520. - request (Request): FastAPI请求对象
  521. - redis (Redis): Redis客户端对象
  522. - db (AsyncSession): 数据库会话对象
  523. - token (str): 免登录Token
  524. 返回:
  525. - JWTOutSchema: JWT令牌信息
  526. 异常:
  527. - CustomException: Token无效或过期时抛出异常
  528. """
  529. from sqlalchemy import select
  530. from app.api.v1.module_system.user.model import UserModel
  531. # 验证Token
  532. token_key = f"{cls.AUTO_LOGIN_PREFIX}{token}"
  533. token_data_str = await RedisCURD(redis).get(token_key)
  534. if not token_data_str:
  535. raise CustomException(msg="免登录Token已过期或无效")
  536. token_data = json.loads(token_data_str)
  537. user_id = token_data.get("user_id")
  538. # 查询用户
  539. stmt = select(UserModel).where(UserModel.id == user_id)
  540. result = await db.execute(stmt)
  541. user = result.scalar_one_or_none()
  542. if not user:
  543. raise CustomException(msg="用户不存在")
  544. if user.status == "1":
  545. raise CustomException(msg="用户已被停用")
  546. # 删除已使用的Token
  547. await RedisCURD(redis).delete(token_key)
  548. # 使用LoginService创建token
  549. jwt_token = await LoginService.create_token_service(
  550. request=request, redis=redis, user=user, login_type="PC端"
  551. )
  552. log.info(f"用户{user.username}免登录成功")
  553. return jwt_token
  554. class SmsCodeService:
  555. """短信验证码服务"""
  556. SMS_CODE_PREFIX = "sms_code"
  557. SMS_CODE_EXPIRE = 60 * 10
  558. @classmethod
  559. async def send_sms_code_service(
  560. cls, sms_code: SmsCodeSchema, redis: Redis
  561. ) -> bool:
  562. """
  563. 发送短信验证码
  564. 参数:
  565. - smsCode (SmsCodeSchema): 短信验证码请求模型
  566. - redis (Redis): Redis客户端对象
  567. 异常:
  568. - CustomException: 验证码发送失败时抛出异常。
  569. """
  570. template = SmsTemplateEnum.get_template_by_name(sms_code.template_name)
  571. code = generate_random_code(6)
  572. redis_key = f"{cls.SMS_CODE_PREFIX}:{sms_code.template_name}:{sms_code.mobile}"
  573. await RedisCURD(redis).set(
  574. key=redis_key,
  575. value=code,
  576. expire=cls.SMS_CODE_EXPIRE,
  577. )
  578. request = SendSmsRequest(
  579. phone_numbers=sms_code.mobile,
  580. template_code=template.template_code,
  581. template_param=template.template_param_fn(code=code),
  582. )
  583. return await SmsSender.send_sms(request)
  584. @classmethod
  585. async def verify_sms_code_service(
  586. cls, sms_code: SmsCodeSchema, redis: Redis
  587. ) -> bool:
  588. """
  589. 验证短信验证码
  590. 参数:
  591. - smsCode (SmsCodeSchema): 短信验证码请求模型
  592. - redis (Redis): Redis客户端对象
  593. 返回:
  594. - bool: 验证结果
  595. 异常:
  596. - CustomException: 验证码验证失败时抛出异常。
  597. """
  598. redis_key = f"{cls.SMS_CODE_PREFIX}:{sms_code.template_name}:{sms_code.mobile}"
  599. code = await RedisCURD(redis).get(redis_key)
  600. if not code or code != sms_code.code:
  601. return False
  602. await RedisCURD(redis).delete(redis_key)
  603. return True