diff --git a/README.md b/README.md index 21aa4e7..ff5f847 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ reset-local-data.bat - 证书手动新增、后台预览、后台下载、作废、重置链接。 - Excel 模板下载、导入、确认导入、导出证书。 - 操作日志中文显示。 -- 公开查询、证书直达页、PDF 下载。 +- 公开查询(支持证书编号+姓名+图形验证码、手机号+短信验证码两种方式)、证书直达页、PDF 下载。 ## 正式工程 diff --git a/backend/app/api/routes/public.py b/backend/app/api/routes/public.py index f772e61..be2115e 100644 --- a/backend/app/api/routes/public.py +++ b/backend/app/api/routes/public.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index add4553..adc81d2 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/services/pdf.py b/backend/app/services/pdf.py index a544a29..b49f464 100644 --- a/backend/app/services/pdf.py +++ b/backend/app/services/pdf.py @@ -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) diff --git a/backend/app/services/sms.py b/backend/app/services/sms.py new file mode 100644 index 0000000..5f5deff --- /dev/null +++ b/backend/app/services/sms.py @@ -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) diff --git a/backend/docs/aliyun-sms-deployment.md b/backend/docs/aliyun-sms-deployment.md new file mode 100644 index 0000000..4573926 --- /dev/null +++ b/backend/docs/aliyun-sms-deployment.md @@ -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) diff --git a/docs/decision-log.md b/docs/decision-log.md index 1ef8097..98d4f30 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -18,9 +18,9 @@ ### D003 公开查询方式 -- 决定:一期公开查询支持两种方式:证书编号 + 姓名 + 图形验证码,或手机号 + 姓名 + 图形验证码。 -- 说明:手机号 + 姓名可能匹配多张证书,查询结果按列表展示。 -- 风控:继续保留图形验证码和基础频率限制,降低批量撞库风险。 +- 决定:一期公开查询支持两种方式:证书编号 + 姓名 + 图形验证码,或手机号 + 短信验证码。 +- 说明:短信验证码查询无需姓名验证,通过手机号匹配可能返回多张证书,查询结果按列表展示。 +- 风控:短信验证码有效期5分钟,同一手机号5分钟内最多发送3次,同一IP每分钟最多发送5次,查询接口保留基础频率限制。 ### D004 作废证书 PDF diff --git a/docs/deployment-checklist.md b/docs/deployment-checklist.md index 850c19d..3c4eb6a 100644 --- a/docs/deployment-checklist.md +++ b/docs/deployment-checklist.md @@ -44,7 +44,9 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a - 可新增证书并生成编号。 - 可上传 Excel 并确认导入。 - 可导出证书和直达链接。 -- 公开查询必须输入验证码。 +- 公开查询支持两种方式: + - 证书编号查询:必须输入姓名和图形验证码。 + - 短信验证码查询:必须输入手机号和短信验证码。 - 作废证书不能下载正常 PDF。 - 直达链接重置后旧链接失效。 - PDF 可生成,二维码可打开核验页。 diff --git a/docs/mvp-scope.md b/docs/mvp-scope.md index 9907014..76be57c 100644 --- a/docs/mvp-scope.md +++ b/docs/mvp-scope.md @@ -9,7 +9,7 @@ 5. Excel 批量导入,导入前校验,导入后生成证书编号和公开访问 token。 6. 证书编号系统自动生成,格式为 `PX{YYYY}-{项目代码}-{7位短码}-{2位校验码}`。 7. 批量导出证书数据,包含证书编号和对外直达链接。 -8. 公开查询:证书编号 + 姓名 + 图形验证码。 +8. 公开查询:证书编号 + 姓名 + 图形验证码,或手机号 + 短信验证码。 9. 证书直达页:通过不可枚举 token 查看单张证书。 10. 二维码核验页:展示真实性、状态和必要证书信息。 11. PDF 下载:服务端按需生成,占位模板,缓存 30 天。 diff --git a/docs/usage-deployment-manual.md b/docs/usage-deployment-manual.md index 288fefa..e5af314 100644 --- a/docs/usage-deployment-manual.md +++ b/docs/usage-deployment-manual.md @@ -49,8 +49,10 @@ reset-local-data.bat ## 公开查询流程 1. 用户进入 `/query`。 -2. 输入证书编号 + 姓名 + 图形验证码,或手机号 + 姓名 + 图形验证码。 -3. 查询成功后展示证书状态和证书信息;手机号查询可能返回多张证书。 +2. 选择查询方式: + - 证书编号查询:输入证书编号 + 姓名 + 图形验证码。 + - 短信验证码查询:输入手机号 + 获取并输入短信验证码。 +3. 查询成功后展示证书状态和证书信息;短信验证码查询可能返回多张证书。 4. 有效证书可下载 PDF。 5. 作废证书只展示作废状态,不提供正常 PDF 下载。 diff --git a/frontend/src/views/AdminCertificates.vue b/frontend/src/views/AdminCertificates.vue index 20b7938..0775472 100644 --- a/frontend/src/views/AdminCertificates.vue +++ b/frontend/src/views/AdminCertificates.vue @@ -25,7 +25,8 @@ - + + 证书上将显示为「{输入内容}课程的专业学习」,留空则默认显示「初级课程的专业学习」 @@ -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; +} diff --git a/frontend/src/views/CertificateQuery.vue b/frontend/src/views/CertificateQuery.vue index 2f193d0..789036f 100644 --- a/frontend/src/views/CertificateQuery.vue +++ b/frontend/src/views/CertificateQuery.vue @@ -13,13 +13,10 @@ - - - - + - + @@ -28,6 +25,31 @@ + + + + + + + + + 刷新 + + + + + + + + {{ smsCooldown > 0 ? `${smsCooldown}秒后重试` : '获取验证码' }} + + + 查询 @@ -111,7 +133,7 @@ - + + + + 培训结业证书查询 - + 输入信息查询您的培训证书 + + + + + 选择查询方式 证书编号查询 - 手机号查询 + 短信验证码查询 - - - - - - - 刷新 - 查询 + + + + + 证书信息 + + + + - 本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。 + + 审核验证 + + + + 刷新 + + + + + + + 手机号 + + 短信验证码 + + + 获取验证码 + + + + 查询证书 + + + + + 本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。 - + + + + + + + + 验证身份 + × + + 请输入图形验证码以获取短信验证码 + + + + 刷新 + + + + + @@ -130,7 +1062,7 @@ 登录 - 本地验收默认账号:admin / Admin123! + 本地验收默认账号:admin / Admin123! @@ -174,6 +1106,10 @@
输入信息查询您的培训证书
本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。
请输入图形验证码以获取短信验证码
本地验收默认账号:admin / Admin123!