增加手机号短信验证查询逻辑

This commit is contained in:
2026-06-09 16:05:37 +08:00
parent ce4ed6213b
commit f1d7491288
14 changed files with 1942 additions and 136 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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
View 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)

View File

@@ -0,0 +1,329 @@
# 阿里云短信服务部署指南
## 前置条件
- 阿里云账号(已实名认证)
- 已备案的域名(短信签名需要)
- 服务器(已部署后端服务)
## 第一步:开通短信服务
1. 登录阿里云控制台https://console.aliyun.com
2. 搜索「短信服务」或访问https://dysms.console.aliyun.com
3. 点击「免费开通」
4. 阅读并同意服务协议
5. 点击「立即开通」
## 第二步:申请短信签名
短信签名是短信开头的标识,如【培训结业系统】。
1. 进入短信服务控制台
2. 左侧菜单选择「国内消息」→「签名管理」
3. 点击「添加签名」
4. 填写信息:
- **签名名称**培训结业证书系统或其他名称2-12个字符
- **适用场景**:选择「自用-验证码」
- **签名来源**:选择「企事业单位的全称或简称」
- **上传证明**:营业执照照片
- **申请说明**:用于学员查询培训结业证书时的短信验证码
5. 点击「提交」
6. 等待审核通常1-2小时
## 第三步:申请短信模板
短信模板是短信内容的格式。
1. 左侧菜单选择「国内消息」→「模板管理」
2. 点击「添加模板」
3. 填写信息:
- **模板名称**:验证码模板
- **模板类型**:选择「验证码」
- **模板内容**`您的验证码是${code}5分钟内有效请勿泄露给他人。`
- **申请说明**:用于学员查询培训结业证书时的身份验证
4. 点击「提交」
5. 等待审核通常1-2小时
**注意**:模板变量格式必须是 `${变量名}`,不能是其他格式。
## 第四步:获取 AccessKey
AccessKey 用于后端调用阿里云 API。
1. 点击右上角头像 →「AccessKey管理」
2. 或直接访问https://ram.console.aliyun.com/manage/ak
3. 点击「创建AccessKey」
4. 进行身份验证(短信验证码或邮箱验证)
5. 创建成功后,保存:
- **AccessKey ID**
- **AccessKey Secret**
**重要**AccessKey Secret 只显示一次,请立即保存到安全的地方。
**安全建议**
- 不要将 AccessKey 写在代码里
- 使用 RAM 子账号的 AccessKey并只授予短信服务权限
- 定期轮换 AccessKey
## 第五步:配置环境变量
在服务器上配置环境变量,编辑 `.env` 文件或系统环境变量:
```bash
# 阿里云短信服务配置
ALIYUN_ACCESS_KEY_ID=你的AccessKeyID
ALIYUN_ACCESS_KEY_SECRET=你的AccessKeySecret
ALIYUN_SMS_SIGN_NAME=你的短信签名
ALIYUN_SMS_TEMPLATE_CODE=你的模板CODE
```
**示例**
```bash
ALIYUN_ACCESS_KEY_ID=LTAI5tQkB3VRg8xR
ALIYUN_ACCESS_KEY_SECRET=your_secret_key_here
ALIYUN_SMS_SIGN_NAME=培训结业系统
ALIYUN_SMS_TEMPLATE_CODE=SMS_123456789
```
## 第六步:安装依赖
```bash
cd backend
pip install alibabacloud-dysmsapi20170525==3.0.0
```
或在 `requirements.txt` 中添加:
```
alibabacloud-dysmsapi20170525==3.0.0
```
然后执行:
```bash
pip install -r requirements.txt
```
## 第七步:配置后端代码
编辑 `backend/app/core/config.py`,添加短信配置:
```python
import os
class Settings:
# ... 其他配置 ...
# 阿里云短信配置
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", "")
settings = Settings()
```
## 第八步:修改短信发送服务
编辑 `backend/app/services/sms.py`,替换为真实的短信发送逻辑:
```python
import logging
import random
import secrets
import string
import time
from alibabacloud_dysmsapi20170525.client import Client
from alibabacloud_dysmsapi20170525 import models as dysmsapi_models
from alibabacloud_tea_openapi import models as open_api_models
from app.core.config import settings
logger = logging.getLogger(__name__)
SMS_CODE_TTL_SECONDS = 300 # 5分钟有效期
_sms_codes: dict[str, tuple[str, float]] = {}
def _get_sms_client() -> Client:
"""获取阿里云短信客户端"""
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"
return Client(config)
def send_sms(phone: str, code: str) -> bool:
"""发送短信验证码"""
try:
client = _get_sms_client()
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 Exception as e:
logger.error(f"[短信发送异常] 手机号: {phone}, 异常: {str(e)}")
return False
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)
# 开发环境下记录日志
logger.info(f"[短信验证码] 手机号: {phone}, 验证码: {code}, 有效期: {SMS_CODE_TTL_SECONDS}")
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)
```
## 第九步:测试验证
### 1. 检查配置
```bash
# 查看环境变量
echo $ALIYUN_ACCESS_KEY_ID
echo $ALIYUN_SMS_SIGN_NAME
echo $ALIYUN_SMS_TEMPLATE_CODE
```
### 2. 重启服务
```bash
# 重启后端服务
systemctl restart backend
# 或
pm2 restart backend
```
### 3. 查看日志
```bash
# 查看日志
tail -f /var/log/backend/app.log
```
### 4. 测试发送
访问公开查询页面,输入手机号,点击「获取验证码」,查看:
- 是否收到短信
- 日志是否有发送记录
## 常见问题排查
### 1. 签名或模板审核不通过
**原因**
- 签名与备案域名不一致
- 模板内容不符合规范
- 证明材料不清晰
**解决**
- 确保签名与备案域名一致
- 模板变量格式使用 `${变量名}`
- 上传清晰的营业执照照片
### 2. 发送失败,返回错误码
**常见错误码**
- `isv.BUSINESS_LIMIT_CONTROL`:发送频率超限
- `isv.INVALID_PARAMETERS`:参数错误
- `isv.SMS_SIGNATURE_ILLEGAL`:签名不合法
- `isv.SMS_TEMPLATE_ILLEGAL`:模板不合法
**解决**
- 检查签名和模板是否审核通过
- 检查手机号格式是否正确
- 检查模板变量格式是否正确
### 3. 发送成功但收不到短信
**可能原因**
- 手机号被运营商拦截
- 短信内容触发风控
- 手机号在黑名单中
**解决**
- 换个手机号测试
- 检查短信内容是否合规
- 联系阿里云客服
### 4. AccessKey 权限不足
**错误信息**
```
Code: InvalidAccessKeyId.NotFound
```
**解决**
- 检查 AccessKey ID 是否正确
- 检查 AccessKey 是否已启用
- 检查 RAM 子账号权限
## 费用说明
- **短信费用**0.045元/条(国内)
- **免费额度**新用户有100条免费额度
- **计费方式**:按条计费,发送成功才计费
## 安全建议
1. **使用 RAM 子账号**
- 创建专门的 RAM 子账号
- 只授予「短信服务」权限
- 不使用主账号的 AccessKey
2. **限制发送频率**
- 同一手机号1条/分钟5条/小时
- 同一IP5条/分钟
3. **监控告警**
- 设置短信发送量告警
- 监控异常发送行为
4. **定期轮换 AccessKey**
- 建议每3个月轮换一次
- 轮换时先创建新 Key再删除旧 Key
## 相关文档
- [阿里云短信服务官方文档](https://help.aliyun.com/product/44286.html)
- [短信服务API参考](https://help.aliyun.com/document_detail/101414.html)
- [短信服务错误码](https://help.aliyun.com/document_detail/101345.html)