增加手机号短信验证查询逻辑
This commit is contained in:
@@ -10,12 +10,19 @@ from app.services.captcha import make_captcha, verify_captcha
|
||||
from app.services.logs import log_action, mask_phone
|
||||
from app.services.pdf import render_certificate_pdf
|
||||
from app.services.rate_limit import hit_too_often
|
||||
from app.services.sms import generate_sms_code, verify_sms_code
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DISCLAIMER = "本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。"
|
||||
|
||||
|
||||
class SendSmsRequest(BaseModel):
|
||||
phone: str = Field(min_length=1, max_length=32)
|
||||
captcha_id: str = Field(min_length=1, max_length=128)
|
||||
captcha_code: str = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class CertificateSearchRequest(BaseModel):
|
||||
certificate_no: str | None = Field(default=None, max_length=64)
|
||||
phone: str | None = Field(default=None, max_length=32)
|
||||
@@ -30,11 +37,35 @@ class CertificateSearchRequest(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class SmsSearchRequest(BaseModel):
|
||||
phone: str = Field(min_length=1, max_length=32)
|
||||
sms_id: str = Field(min_length=1, max_length=128)
|
||||
sms_code: str = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
@router.get("/captcha")
|
||||
def get_captcha() -> dict[str, str]:
|
||||
return make_captcha()
|
||||
|
||||
|
||||
@router.post("/sms/send")
|
||||
def send_sms_code(
|
||||
payload: SendSmsRequest,
|
||||
request: Request,
|
||||
) -> dict[str, str]:
|
||||
"""发送短信验证码"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
if hit_too_often(f"sms:{client_ip}", limit=5, window_seconds=60):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="发送过于频繁,请稍后再试")
|
||||
if hit_too_often(f"sms:{payload.phone}", limit=3, window_seconds=300):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="该手机号发送过于频繁,请稍后再试")
|
||||
if not verify_captcha(payload.captcha_id, payload.captcha_code):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码错误或已过期,请重新输入")
|
||||
|
||||
sms_id = generate_sms_code(payload.phone)
|
||||
return {"sms_id": sms_id, "message": "验证码已发送"}
|
||||
|
||||
|
||||
@router.post("/certificates/search")
|
||||
def search_certificate(
|
||||
payload: CertificateSearchRequest,
|
||||
@@ -71,6 +102,39 @@ def search_certificate(
|
||||
return {"items": [public_certificate_payload(db, certificate, learner) for certificate, learner in result]}
|
||||
|
||||
|
||||
@router.post("/certificates/search-by-sms")
|
||||
def search_certificate_by_sms(
|
||||
payload: SmsSearchRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, object]:
|
||||
"""通过手机号和短信验证码查询证书"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
if hit_too_often(f"search_sms:{client_ip}", limit=30, window_seconds=60):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="访问过于频繁,请稍后再试")
|
||||
|
||||
if not verify_sms_code(payload.sms_id, payload.sms_code):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期,请重新获取")
|
||||
|
||||
phone = payload.phone.strip()
|
||||
|
||||
query = db.query(Certificate, Learner).join(Learner, Learner.id == Certificate.learner_id)
|
||||
result = (
|
||||
query.filter(Learner.phone == phone)
|
||||
.order_by(Certificate.issue_date.desc(), Certificate.id.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if not result:
|
||||
log_action(db, None, "public_search_certificate_sms", "public_access", detail={"mode": "phone_sms", "phone": mask_phone(phone), "matched_count": 0, "result": "not_found"})
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未查询到匹配的证书信息,请核对手机号后重试")
|
||||
|
||||
log_action(db, None, "public_search_certificate_sms", "public_access", result[0][0].certificate_no, {"mode": "phone_sms", "phone": mask_phone(phone), "matched_count": len(result), "result": "matched"})
|
||||
db.commit()
|
||||
return {"items": [public_certificate_payload(db, certificate, learner) for certificate, learner in result]}
|
||||
|
||||
|
||||
@router.get("/certificates/token/{token}")
|
||||
def get_certificate_by_token(token: str, db: Session = Depends(get_db)) -> dict[str, object]:
|
||||
access_token = get_active_token(db, token)
|
||||
|
||||
@@ -23,6 +23,14 @@ class Settings:
|
||||
cors_origins: list[str] = field(
|
||||
default_factory=lambda: _csv_env("CORS_ORIGINS", ["http://localhost:5173", "http://localhost:8080"])
|
||||
)
|
||||
|
||||
# 短信服务配置
|
||||
# local=本地测试(验证码打印到终端),aliyun=阿里云短信
|
||||
sms_mode: str = os.getenv("SMS_MODE", "local")
|
||||
aliyun_access_key_id: str = os.getenv("ALIYUN_ACCESS_KEY_ID", "")
|
||||
aliyun_access_key_secret: str = os.getenv("ALIYUN_ACCESS_KEY_SECRET", "")
|
||||
aliyun_sms_sign_name: str = os.getenv("ALIYUN_SMS_SIGN_NAME", "")
|
||||
aliyun_sms_template_code: str = os.getenv("ALIYUN_SMS_TEMPLATE_CODE", "")
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -51,7 +51,8 @@ def render_certificate_image(certificate: Certificate, learner: Learner, project
|
||||
|
||||
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
|
||||
course_name = certificate.course_name or certificate.certificate_name or (project.name if project else certificate.project_code)
|
||||
stage_name = certificate.stage_name or "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
||||
stage_input = (certificate.stage_name or "").strip()
|
||||
stage_name = f"{stage_input}\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002" if stage_input else "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
||||
|
||||
for box in [(150, 442, 760, 132), (404, 675, 220, 38)]:
|
||||
paper_patch(image, box)
|
||||
|
||||
100
backend/app/services/sms.py
Normal file
100
backend/app/services/sms.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import logging
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SMS_CODE_TTL_SECONDS = 300 # 5分钟有效期
|
||||
_sms_codes: dict[str, tuple[str, float]] = {}
|
||||
|
||||
|
||||
def _send_sms_aliyun(phone: str, code: str) -> bool:
|
||||
"""通过阿里云发送短信"""
|
||||
try:
|
||||
from alibabacloud_dysmsapi20170525.client import Client
|
||||
from alibabacloud_dysmsapi20170525 import models as dysmsapi_models
|
||||
from alibabacloud_tea_openapi import models as open_api_models
|
||||
|
||||
config = open_api_models.Config(
|
||||
access_key_id=settings.aliyun_access_key_id,
|
||||
access_key_secret=settings.aliyun_access_key_secret,
|
||||
)
|
||||
config.endpoint = "dysmsapi.aliyuncs.com"
|
||||
client = Client(config)
|
||||
|
||||
request = dysmsapi_models.SendSmsRequest(
|
||||
phone_numbers=phone,
|
||||
sign_name=settings.aliyun_sms_sign_name,
|
||||
template_code=settings.aliyun_sms_template_code,
|
||||
template_param=f'{{"code":"{code}"}}',
|
||||
)
|
||||
response = client.send_sms(request)
|
||||
|
||||
if response.body.code == "OK":
|
||||
logger.info(f"[阿里云短信] 发送成功: {phone}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"[阿里云短信] 发送失败: {phone}, 错误: {response.body.message}")
|
||||
return False
|
||||
except ImportError:
|
||||
logger.error("[阿里云短信] 未安装依赖,请执行: pip install alibabacloud-dysmsapi20170525")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[阿里云短信] 发送异常: {phone}, 异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def _send_sms_local(phone: str, code: str) -> bool:
|
||||
"""本地测试模式:将验证码打印到终端"""
|
||||
print(f"\n{'='*50}")
|
||||
print(f"[短信验证码] 手机号: {phone}")
|
||||
print(f"[短信验证码] 验证码: {code}")
|
||||
print(f"[短信验证码] 有效期: {SMS_CODE_TTL_SECONDS}秒")
|
||||
print(f"{'='*50}\n")
|
||||
logger.info(f"[本地短信] 验证码已生成: {phone} -> {code}")
|
||||
return True
|
||||
|
||||
|
||||
def send_sms(phone: str, code: str) -> bool:
|
||||
"""发送短信验证码(根据配置选择发送方式)"""
|
||||
if settings.sms_mode == "aliyun":
|
||||
return _send_sms_aliyun(phone, code)
|
||||
else:
|
||||
return _send_sms_local(phone, code)
|
||||
|
||||
|
||||
def generate_sms_code(phone: str) -> str:
|
||||
"""生成短信验证码"""
|
||||
clean_expired_codes()
|
||||
code = "".join(random.choices(string.digits, k=6))
|
||||
sms_id = secrets.token_urlsafe(16)
|
||||
_sms_codes[sms_id] = (code, time.time() + SMS_CODE_TTL_SECONDS)
|
||||
|
||||
# 发送短信
|
||||
send_sms(phone, code)
|
||||
|
||||
return sms_id
|
||||
|
||||
|
||||
def verify_sms_code(sms_id: str, code: str) -> bool:
|
||||
"""验证短信验证码"""
|
||||
clean_expired_codes()
|
||||
record = _sms_codes.pop(sms_id, None)
|
||||
if not record:
|
||||
return False
|
||||
expected, expires_at = record
|
||||
if expires_at < time.time():
|
||||
return False
|
||||
return expected == code.strip()
|
||||
|
||||
|
||||
def clean_expired_codes() -> None:
|
||||
"""清理过期的验证码"""
|
||||
now = time.time()
|
||||
expired = [sms_id for sms_id, (_, expires_at) in _sms_codes.items() if expires_at < now]
|
||||
for sms_id in expired:
|
||||
_sms_codes.pop(sms_id, None)
|
||||
Reference in New Issue
Block a user