增加手机号短信验证查询逻辑
This commit is contained in:
@@ -48,7 +48,7 @@ reset-local-data.bat
|
||||
- 证书手动新增、后台预览、后台下载、作废、重置链接。
|
||||
- Excel 模板下载、导入、确认导入、导出证书。
|
||||
- 操作日志中文显示。
|
||||
- 公开查询、证书直达页、PDF 下载。
|
||||
- 公开查询(支持证书编号+姓名+图形验证码、手机号+短信验证码两种方式)、证书直达页、PDF 下载。
|
||||
|
||||
## 正式工程
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,6 +24,14 @@ class Settings:
|
||||
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
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -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)
|
||||
329
backend/docs/aliyun-sms-deployment.md
Normal file
329
backend/docs/aliyun-sms-deployment.md
Normal 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条/小时
|
||||
- 同一IP:5条/分钟
|
||||
|
||||
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)
|
||||
@@ -18,9 +18,9 @@
|
||||
|
||||
### D003 公开查询方式
|
||||
|
||||
- 决定:一期公开查询支持两种方式:证书编号 + 姓名 + 图形验证码,或手机号 + 姓名 + 图形验证码。
|
||||
- 说明:手机号 + 姓名可能匹配多张证书,查询结果按列表展示。
|
||||
- 风控:继续保留图形验证码和基础频率限制,降低批量撞库风险。
|
||||
- 决定:一期公开查询支持两种方式:证书编号 + 姓名 + 图形验证码,或手机号 + 短信验证码。
|
||||
- 说明:短信验证码查询无需姓名验证,通过手机号匹配可能返回多张证书,查询结果按列表展示。
|
||||
- 风控:短信验证码有效期5分钟,同一手机号5分钟内最多发送3次,同一IP每分钟最多发送5次,查询接口保留基础频率限制。
|
||||
|
||||
### D004 作废证书 PDF
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
|
||||
- 可新增证书并生成编号。
|
||||
- 可上传 Excel 并确认导入。
|
||||
- 可导出证书和直达链接。
|
||||
- 公开查询必须输入验证码。
|
||||
- 公开查询支持两种方式:
|
||||
- 证书编号查询:必须输入姓名和图形验证码。
|
||||
- 短信验证码查询:必须输入手机号和短信验证码。
|
||||
- 作废证书不能下载正常 PDF。
|
||||
- 直达链接重置后旧链接失效。
|
||||
- PDF 可生成,二维码可打开核验页。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
5. Excel 批量导入,导入前校验,导入后生成证书编号和公开访问 token。
|
||||
6. 证书编号系统自动生成,格式为 `PX{YYYY}-{项目代码}-{7位短码}-{2位校验码}`。
|
||||
7. 批量导出证书数据,包含证书编号和对外直达链接。
|
||||
8. 公开查询:证书编号 + 姓名 + 图形验证码。
|
||||
8. 公开查询:证书编号 + 姓名 + 图形验证码,或手机号 + 短信验证码。
|
||||
9. 证书直达页:通过不可枚举 token 查看单张证书。
|
||||
10. 二维码核验页:展示真实性、状态和必要证书信息。
|
||||
11. PDF 下载:服务端按需生成,占位模板,缓存 30 天。
|
||||
|
||||
@@ -49,8 +49,10 @@ reset-local-data.bat
|
||||
## 公开查询流程
|
||||
|
||||
1. 用户进入 `/query`。
|
||||
2. 输入证书编号 + 姓名 + 图形验证码,或手机号 + 姓名 + 图形验证码。
|
||||
3. 查询成功后展示证书状态和证书信息;手机号查询可能返回多张证书。
|
||||
2. 选择查询方式:
|
||||
- 证书编号查询:输入证书编号 + 姓名 + 图形验证码。
|
||||
- 短信验证码查询:输入手机号 + 获取并输入短信验证码。
|
||||
3. 查询成功后展示证书状态和证书信息;短信验证码查询可能返回多张证书。
|
||||
4. 有效证书可下载 PDF。
|
||||
5. 作废证书只展示作废状态,不提供正常 PDF 下载。
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
<el-input v-model.trim="form.course_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="阶段名称">
|
||||
<el-input v-model.trim="form.stage_name" />
|
||||
<el-input v-model.trim="form.stage_name" placeholder="如:全部、初级、中级" />
|
||||
<div class="form-tip">证书上将显示为「{输入内容}课程的专业学习」,留空则默认显示「初级课程的专业学习」</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="发证单位">
|
||||
<el-input v-model.trim="form.issuer_name" />
|
||||
@@ -213,4 +214,11 @@ onMounted(async () => {
|
||||
.toolbar .el-input {
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,13 +13,10 @@
|
||||
<el-form-item v-if="queryMode === 'certificate'" label="证书编号">
|
||||
<el-input v-model.trim="form.certificateNo" placeholder="例如 PX2026-DBY-K7M9Q2R-83" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="手机号">
|
||||
<el-input v-model.trim="form.phone" placeholder="请输入证书对应手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名">
|
||||
<el-form-item v-if="queryMode === 'certificate'" label="姓名">
|
||||
<el-input v-model.trim="form.name" placeholder="请输入证书对应姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图形验证码">
|
||||
<el-form-item v-if="queryMode === 'certificate'" label="图形验证码">
|
||||
<div class="captcha-row">
|
||||
<el-input v-model.trim="form.captcha" placeholder="请输入验证码" />
|
||||
<button class="captcha-button" type="button" @click="loadCaptcha">
|
||||
@@ -28,6 +25,31 @@
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="queryMode === 'sms'" label="手机号">
|
||||
<el-input v-model.trim="form.phone" placeholder="请输入证书对应手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="queryMode === 'sms'" label="图形验证码">
|
||||
<div class="captcha-row">
|
||||
<el-input v-model.trim="form.smsCaptcha" placeholder="请输入验证码" />
|
||||
<button class="captcha-button" type="button" @click="loadSmsCaptcha">
|
||||
<img v-if="smsCaptchaImage" :src="smsCaptchaImage" alt="验证码" />
|
||||
<span v-else>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="queryMode === 'sms'" label="短信验证码">
|
||||
<div class="sms-row">
|
||||
<el-input v-model.trim="form.smsCode" placeholder="请输入短信验证码" />
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="smsCooldown > 0 || !form.phone"
|
||||
@click="sendSmsCode"
|
||||
:loading="sendingSms"
|
||||
>
|
||||
{{ smsCooldown > 0 ? `${smsCooldown}秒后重试` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" class="submit" :loading="loading" @click="submitQuery">查询</el-button>
|
||||
</el-form>
|
||||
|
||||
@@ -111,7 +133,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from "vue";
|
||||
import { onMounted, onUnmounted, reactive, ref, watch } from "vue";
|
||||
|
||||
import { http, type Captcha, type PublicCertificate } from "../api";
|
||||
|
||||
@@ -121,13 +143,15 @@ interface SearchResponse {
|
||||
|
||||
const queryOptions = [
|
||||
{ label: "证书编号查询", value: "certificate" },
|
||||
{ label: "手机号查询", value: "phone" },
|
||||
{ label: "短信验证码查询", value: "sms" },
|
||||
];
|
||||
|
||||
const queryMode = ref<"certificate" | "phone">("certificate");
|
||||
const form = reactive({ certificateNo: "", phone: "", name: "", captcha: "" });
|
||||
const queryMode = ref<"certificate" | "sms">("certificate");
|
||||
const form = reactive({ certificateNo: "", phone: "", name: "", captcha: "", smsCaptcha: "", smsCode: "" });
|
||||
const captchaId = ref("");
|
||||
const captchaImage = ref("");
|
||||
const smsCaptchaId = ref("");
|
||||
const smsCaptchaImage = ref("");
|
||||
const loading = ref(false);
|
||||
const message = ref("");
|
||||
const messageType = ref<"success" | "warning" | "error">("warning");
|
||||
@@ -135,6 +159,10 @@ const results = ref<PublicCertificate[]>([]);
|
||||
const previewVisible = ref(false);
|
||||
const current = ref<PublicCertificate | null>(null);
|
||||
const previewPdfUrl = ref("");
|
||||
const smsId = ref("");
|
||||
const sendingSms = ref(false);
|
||||
const smsCooldown = ref(0);
|
||||
let smsTimer: number | null = null;
|
||||
|
||||
function statusText(status: string) {
|
||||
return status === "valid" ? "有效" : "已作废";
|
||||
@@ -147,6 +175,9 @@ function courseText(item: PublicCertificate) {
|
||||
watch(queryMode, () => {
|
||||
message.value = "";
|
||||
results.value = [];
|
||||
if (queryMode.value === "sms") {
|
||||
loadSmsCaptcha();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadCaptcha() {
|
||||
@@ -156,18 +187,86 @@ async function loadCaptcha() {
|
||||
form.captcha = "";
|
||||
}
|
||||
|
||||
async function loadSmsCaptcha() {
|
||||
const { data } = await http.get<Captcha>("/public/captcha");
|
||||
smsCaptchaId.value = data.captcha_id;
|
||||
smsCaptchaImage.value = data.image;
|
||||
form.smsCaptcha = "";
|
||||
}
|
||||
|
||||
async function sendSmsCode() {
|
||||
if (!form.phone) {
|
||||
message.value = "请先输入手机号";
|
||||
messageType.value = "warning";
|
||||
return;
|
||||
}
|
||||
|
||||
sendingSms.value = true;
|
||||
message.value = "";
|
||||
|
||||
try {
|
||||
const { data } = await http.post<{ sms_id: string; message: string }>("/public/sms/send", {
|
||||
phone: form.phone,
|
||||
captcha_id: smsCaptchaId.value,
|
||||
captcha_code: form.smsCaptcha,
|
||||
});
|
||||
smsId.value = data.sms_id;
|
||||
message.value = data.message;
|
||||
messageType.value = "success";
|
||||
|
||||
// 开始倒计时
|
||||
smsCooldown.value = 60;
|
||||
if (smsTimer) clearInterval(smsTimer);
|
||||
smsTimer = window.setInterval(() => {
|
||||
smsCooldown.value--;
|
||||
if (smsCooldown.value <= 0) {
|
||||
if (smsTimer) clearInterval(smsTimer);
|
||||
smsTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error: any) {
|
||||
message.value = error?.response?.data?.detail || "发送失败,请稍后再试";
|
||||
messageType.value = "error";
|
||||
await loadSmsCaptcha();
|
||||
} finally {
|
||||
sendingSms.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitQuery() {
|
||||
loading.value = true;
|
||||
message.value = "";
|
||||
results.value = [];
|
||||
|
||||
try {
|
||||
const { data } = await http.post<SearchResponse>("/public/certificates/search", {
|
||||
certificate_no: queryMode.value === "certificate" ? form.certificateNo : null,
|
||||
phone: queryMode.value === "phone" ? form.phone : null,
|
||||
name: form.name,
|
||||
captcha_id: captchaId.value,
|
||||
captcha_code: form.captcha,
|
||||
});
|
||||
let data: SearchResponse;
|
||||
|
||||
if (queryMode.value === "certificate") {
|
||||
// 证书编号查询
|
||||
const response = await http.post<SearchResponse>("/public/certificates/search", {
|
||||
certificate_no: form.certificateNo,
|
||||
phone: null,
|
||||
name: form.name,
|
||||
captcha_id: captchaId.value,
|
||||
captcha_code: form.captcha,
|
||||
});
|
||||
data = response.data;
|
||||
} else {
|
||||
// 短信验证码查询
|
||||
if (!smsId.value) {
|
||||
message.value = "请先获取短信验证码";
|
||||
messageType.value = "warning";
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await http.post<SearchResponse>("/public/certificates/search-by-sms", {
|
||||
phone: form.phone,
|
||||
sms_id: smsId.value,
|
||||
sms_code: form.smsCode,
|
||||
});
|
||||
data = response.data;
|
||||
}
|
||||
|
||||
results.value = data.items;
|
||||
const hasVoided = data.items.some((item) => item.status !== "valid");
|
||||
message.value = hasVoided ? "已查询到证书,其中包含作废证书,请核对状态。" : "已查询到匹配证书。";
|
||||
@@ -175,7 +274,9 @@ async function submitQuery() {
|
||||
} catch (error: any) {
|
||||
message.value = error?.response?.data?.detail || "查询失败,请稍后再试";
|
||||
messageType.value = "error";
|
||||
await loadCaptcha();
|
||||
if (queryMode.value === "certificate") {
|
||||
await loadCaptcha();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -208,6 +309,14 @@ function downloadPdf(item: PublicCertificate) {
|
||||
}
|
||||
|
||||
onMounted(loadCaptcha);
|
||||
|
||||
// 清理定时器
|
||||
onUnmounted(() => {
|
||||
if (smsTimer) {
|
||||
clearInterval(smsTimer);
|
||||
smsTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -258,6 +367,13 @@ h1 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sms-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.captcha-button {
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
|
||||
@@ -3,6 +3,7 @@ import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import random
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
@@ -27,6 +28,7 @@ DB_PATH = DATA / "local.db"
|
||||
SECRET = "local-dev-secret"
|
||||
ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
CAPTCHAS: dict[str, tuple[str, float]] = {}
|
||||
SMS_CODES: dict[str, tuple[str, float]] = {}
|
||||
LOG_RETENTION_DAYS = 180
|
||||
HIGH_RISK_LOG_RETENTION_DAYS = 365
|
||||
|
||||
@@ -312,11 +314,37 @@ def make_captcha() -> dict:
|
||||
code = "".join(secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(4))
|
||||
captcha_id = secrets.token_urlsafe(12)
|
||||
CAPTCHAS[captcha_id] = (code, time.time() + 300)
|
||||
image = Image.new("RGB", (132, 44), "#f8fafc")
|
||||
|
||||
# 增大图片尺寸,提高可读性
|
||||
width, height = 180, 56
|
||||
image = Image.new("RGB", (width, height), "#f0f7f7")
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# 使用更大的字体
|
||||
font = pil_font(28, "hei", True)
|
||||
|
||||
# 绘制背景干扰线
|
||||
for _ in range(6):
|
||||
x1, y1 = random.randint(0, width), random.randint(0, height)
|
||||
x2, y2 = random.randint(0, width), random.randint(0, height)
|
||||
draw.line((x1, y1, x2, y2), fill="#b8d4d4", width=1)
|
||||
|
||||
# 绘制干扰点
|
||||
for _ in range(30):
|
||||
x, y = random.randint(0, width), random.randint(0, height)
|
||||
draw.point((x, y), fill="#a0c8c8")
|
||||
|
||||
# 绘制验证码字符,带随机偏移
|
||||
colors = ["#1a5c5c", "#2d7a7a", "#1f6b6b", "#3a8a8a", "#0e4a4a"]
|
||||
for i, ch in enumerate(code):
|
||||
draw.text((18 + i * 26, 14), ch, fill="#111827", font=font)
|
||||
x = 22 + i * 38 + random.randint(-3, 3)
|
||||
y = 10 + random.randint(-5, 5)
|
||||
color = secrets.choice(colors)
|
||||
draw.text((x, y), ch, fill=color, font=font)
|
||||
|
||||
# 添加边框
|
||||
draw.rectangle((0, 0, width - 1, height - 1), outline="#c8dede", width=1)
|
||||
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, "PNG")
|
||||
return {"captcha_id": captcha_id, "image": "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()}
|
||||
@@ -327,6 +355,41 @@ def verify_captcha(captcha_id: str, code: str) -> bool:
|
||||
return bool(record and record[1] > time.time() and record[0].upper() == code.strip().upper())
|
||||
|
||||
|
||||
def generate_sms_code(phone: str) -> str:
|
||||
"""生成短信验证码"""
|
||||
# 清理过期的验证码
|
||||
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)
|
||||
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
sms_id = secrets.token_urlsafe(16)
|
||||
SMS_CODES[sms_id] = (code, now + 300) # 5分钟有效期
|
||||
|
||||
# 在本地测试环境下,将验证码记录到日志
|
||||
print(f"[短信验证码] 手机号: {phone}, 验证码: {code}, 有效期: 300秒")
|
||||
|
||||
return sms_id
|
||||
|
||||
|
||||
def verify_sms_code(sms_id: str, code: str) -> bool:
|
||||
"""验证短信验证码"""
|
||||
# 清理过期的验证码
|
||||
now = time.time()
|
||||
expired = [sid for sid, (_, expires_at) in SMS_CODES.items() if expires_at < now]
|
||||
for sid in expired:
|
||||
SMS_CODES.pop(sid, None)
|
||||
|
||||
record = SMS_CODES.pop(sms_id, None)
|
||||
if not record:
|
||||
return False
|
||||
expected, expires_at = record
|
||||
if expires_at < now:
|
||||
return False
|
||||
return expected == code.strip()
|
||||
|
||||
|
||||
def parse_multipart(body: bytes, content_type: str) -> tuple[str, bytes]:
|
||||
boundary = content_type.split("boundary=", 1)[1].encode()
|
||||
parts = body.split(b"--" + boundary)
|
||||
@@ -361,7 +424,13 @@ def public_payload(conn: sqlite3.Connection, cert: sqlite3.Row) -> dict:
|
||||
|
||||
|
||||
def font_name() -> str:
|
||||
for path in [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"]:
|
||||
for path in [
|
||||
r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc",
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Songti.ttc",
|
||||
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Heiti.ttc",
|
||||
]:
|
||||
if Path(path).exists():
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont("LocalCJK", path))
|
||||
@@ -373,13 +442,32 @@ def font_name() -> str:
|
||||
|
||||
def pil_font(size: int, kind: str = "song", bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
choices = {
|
||||
"kai": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
|
||||
"hei": [r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc"],
|
||||
"song": [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
|
||||
"kai": [
|
||||
r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Kaiti.ttc",
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Songti.ttc",
|
||||
],
|
||||
"hei": [
|
||||
r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc",
|
||||
"/System/Library/Fonts/STHeiti Medium.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Heiti.ttc",
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
],
|
||||
"song": [
|
||||
r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Songti.ttc",
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||
],
|
||||
}
|
||||
paths = choices.get(kind, choices["song"])
|
||||
if bold and kind == "song":
|
||||
paths = [r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf"] + paths
|
||||
paths = [
|
||||
r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf",
|
||||
"/System/Library/Fonts/STHeiti Medium.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Heiti.ttc",
|
||||
] + paths
|
||||
for item in paths:
|
||||
if Path(item).exists():
|
||||
try:
|
||||
@@ -513,7 +601,8 @@ def render_certificate_image(conn: sqlite3.Connection, cert: sqlite3.Row) -> Ima
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8)
|
||||
course_text = f"\u201c{cert['course_name'] or cert['certificate_name'] or cert['project_code']}\u201d"
|
||||
stage = cert["stage_name"] or "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
||||
stage_input = (cert["stage_name"] or "").strip()
|
||||
stage = 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"
|
||||
draw_inline_flow(draw, [(course_text, "course", 0), (stage, "normal", 14)], x, y)
|
||||
|
||||
issue_label = f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5"
|
||||
@@ -705,6 +794,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.json({"access_token": make_token(admin["id"]), "token_type": "bearer", "username": admin["username"], "role_code": "system_admin"})
|
||||
if path == "/api/public/captcha":
|
||||
return self.json(make_captcha())
|
||||
if path == "/api/public/sms/send":
|
||||
data = json.loads(body or b"{}")
|
||||
phone = data.get("phone", "").strip()
|
||||
if not phone:
|
||||
return self.error(400, "\u8bf7\u8f93\u5165\u624b\u673a\u53f7")
|
||||
if not verify_captcha(data.get("captcha_id", ""), data.get("captcha_code", "")):
|
||||
return self.error(400, "\u56fe\u5f62\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f\uff0c\u8bf7\u91cd\u65b0\u8f93\u5165")
|
||||
sms_id = generate_sms_code(phone)
|
||||
return self.json({"sms_id": sms_id, "message": "\u9a8c\u8bc1\u7801\u5df2\u53d1\u9001"})
|
||||
if path == "/api/public/certificates/search":
|
||||
data = json.loads(body or b"{}")
|
||||
if not verify_captcha(data.get("captcha_id", ""), data.get("captcha_code", "")):
|
||||
@@ -729,6 +827,24 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.error(404, "\u672a\u67e5\u8be2\u5230\u5339\u914d\u7684\u6709\u6548\u8bc1\u4e66\u4fe1\u606f\uff0c\u8bf7\u6838\u5bf9\u540e\u91cd\u8bd5")
|
||||
log(conn, None, "public_search_certificate", "public_access", result[0]["certificate_no"], {"mode": "certificate_no" if query_certificate_no else "phone", "name": name, "certificate_no": query_certificate_no or None, "phone": mask_phone(phone), "matched_count": len(result), "result": "matched"})
|
||||
return self.json({"items": [public_payload(conn, item) for item in result]})
|
||||
if path == "/api/public/certificates/search-by-sms":
|
||||
data = json.loads(body or b"{}")
|
||||
phone = data.get("phone", "").strip()
|
||||
sms_id = data.get("sms_id", "").strip()
|
||||
sms_code = data.get("sms_code", "").strip()
|
||||
if not phone or not sms_id or not sms_code:
|
||||
return self.error(400, "\u8bf7\u8f93\u5165\u624b\u673a\u53f7\u548c\u77ed\u4fe1\u9a8c\u8bc1\u7801")
|
||||
if not verify_sms_code(sms_id, sms_code):
|
||||
return self.error(400, "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f\uff0c\u8bf7\u91cd\u65b0\u83b7\u53d6")
|
||||
result = rows(conn,
|
||||
"select c.* from certificates c join learners l on l.id=c.learner_id where l.phone=? order by c.issue_date desc,c.id desc",
|
||||
(phone,),
|
||||
)
|
||||
if not result:
|
||||
log(conn, None, "public_search_certificate_sms", "public_access", None, {"mode": "phone_sms", "phone": mask_phone(phone), "matched_count": 0, "result": "not_found"})
|
||||
return self.error(404, "\u672a\u67e5\u8be2\u5230\u5339\u914d\u7684\u8bc1\u4e66\u4fe1\u606f\uff0c\u8bf7\u6838\u5bf9\u624b\u673a\u53f7\u540e\u91cd\u8bd5")
|
||||
log(conn, None, "public_search_certificate_sms", "public_access", result[0]["certificate_no"], {"mode": "phone_sms", "phone": mask_phone(phone), "matched_count": len(result), "result": "matched"})
|
||||
return self.json({"items": [public_payload(conn, item) for item in result]})
|
||||
if path.startswith("/api/public/certificates/token/"):
|
||||
token = unquote(path.split("/")[5])
|
||||
cert = conn.execute("select * from certificates where public_token=? or qr_token=?", (token, token)).fetchone()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user