Add Aliyun SMS settings and refresh admin login

This commit is contained in:
2026-07-09 11:02:26 +08:00
parent d78d0ea75f
commit c86b517063
7 changed files with 410 additions and 35 deletions

View File

@@ -233,6 +233,54 @@ const systemSettingSections: SystemSettingSection[] = [
placeholder: "例如 123456", placeholder: "例如 123456",
description: "mock 短信开启时使用的固定验证码。", description: "mock 短信开启时使用的固定验证码。",
}, },
{
key: "aliyun_sms_access_key_id",
label: "阿里云 AccessKey ID",
type: "password",
defaultValue: "",
placeholder: "请输入 RAM 用户 AccessKey ID",
description: "关闭 mock 短信后用于调用阿里云短信服务。",
},
{
key: "aliyun_sms_access_key_secret",
label: "阿里云 AccessKey Secret",
type: "password",
defaultValue: "",
placeholder: "请输入 RAM 用户 AccessKey Secret",
description: "敏感配置,建议使用最小权限 RAM 用户。",
},
{
key: "aliyun_sms_sign_name",
label: "阿里云短信签名",
type: "text",
defaultValue: "",
placeholder: "例如 某某课堂",
description: "需与阿里云短信控制台审核通过的签名一致。",
},
{
key: "aliyun_sms_template_code",
label: "阿里云模板 Code",
type: "text",
defaultValue: "",
placeholder: "例如 SMS_123456789",
description: "验证码短信模板 Code。",
},
{
key: "aliyun_sms_template_param_key",
label: "验证码变量名",
type: "text",
defaultValue: "code",
placeholder: "默认 code",
description: "模板中验证码变量名,例如模板变量为 ${code} 时填写 code。",
},
{
key: "aliyun_sms_endpoint",
label: "阿里云短信 Endpoint",
type: "text",
defaultValue: "dysmsapi.aliyuncs.com",
placeholder: "dysmsapi.aliyuncs.com",
description: "一般保持默认值即可。",
},
{ {
key: "default_user_name_prefix", key: "default_user_name_prefix",
label: "默认用户名前缀", label: "默认用户名前缀",
@@ -984,20 +1032,42 @@ function buildChatQuery() {
<template> <template>
<main v-if="!admin" class="login-page"> <main v-if="!admin" class="login-page">
<section class="login-box"> <section class="login-hero">
<div class="brand">AI</div> <div class="login-hero-main">
<h1>AI 知识库后台</h1> <div class="login-brand">
<p>用于维护用户知识库Agent模型和审计记录</p> <span>AI</span>
<strong>知识库管理后台</strong>
</div>
<h1>让知识库Agent 和用户运营保持在同一个控制台里</h1>
<p>统一维护学员知识库模型配置问答记录和系统运行参数方便排查与交接</p>
<div class="login-highlights">
<span>知识库运营</span>
<span>Agent 调试</span>
<span>记录审计</span>
</div>
</div>
<section class="login-card">
<div class="login-card-head">
<h2>管理员登录</h2>
<p>请输入后台管理员账号和密码</p>
</div>
<el-form label-position="top" @submit.prevent="login"> <el-form label-position="top" @submit.prevent="login">
<el-form-item label="管理员账号"> <el-form-item label="管理员账号">
<el-input v-model="loginForm.username" /> <el-input v-model="loginForm.username" size="large" placeholder="请输入管理员账号" />
</el-form-item> </el-form-item>
<el-form-item label="密码"> <el-form-item label="密码">
<el-input v-model="loginForm.password" type="password" show-password /> <el-input
v-model="loginForm.password"
size="large"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item> </el-form-item>
<el-button type="primary" :loading="loading" class="full-btn" @click="login">登录</el-button> <el-button type="primary" :loading="loading" class="full-btn login-submit" @click="login">登录后台</el-button>
</el-form> </el-form>
</section> </section>
</section>
</main> </main>
<main v-else class="admin-shell"> <main v-else class="admin-shell">

View File

@@ -23,32 +23,146 @@ textarea {
.login-page { .login-page {
min-height: 100vh; min-height: 100vh;
display: grid; display: flex;
place-items: center; align-items: center;
background: #eef4f2; justify-content: center;
padding: 48px;
background:
linear-gradient(135deg, rgba(15, 115, 93, 0.14), rgba(234, 179, 8, 0.08)),
#f4f7f6;
} }
.login-box { .login-hero {
width: 380px; width: min(1080px, 100%);
padding: 32px; min-height: 620px;
display: grid;
grid-template-columns: minmax(0, 1.1fr) 420px;
overflow: hidden;
border: 1px solid #dce6e2; border: 1px solid #dce6e2;
border-radius: 8px; border-radius: 8px;
background: #ffffff; background: #ffffff;
box-shadow: 0 18px 44px rgba(35, 54, 49, 0.08); box-shadow: 0 24px 70px rgba(31, 45, 42, 0.12);
} }
.login-hero-main {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 48px;
background: #12352e;
color: #ffffff;
}
.login-hero-main::after {
content: "";
position: absolute;
right: 42px;
bottom: 42px;
width: 280px;
height: 280px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 50%;
}
.login-brand {
position: relative;
z-index: 1;
display: flex;
gap: 12px;
align-items: center;
font-size: 15px;
}
.login-brand span,
.brand { .brand {
width: 44px; width: 44px;
height: 44px; height: 44px;
display: grid; display: grid;
place-items: center; place-items: center;
margin-bottom: 18px;
border-radius: 8px; border-radius: 8px;
background: #0f8b6f; background: #0f8b6f;
color: #ffffff; color: #ffffff;
font-weight: 800; font-weight: 800;
} }
.login-brand span {
background: #eab308;
color: #17231f;
}
.login-brand strong {
font-size: 16px;
}
.login-hero-main h1 {
position: relative;
z-index: 1;
max-width: 620px;
margin: 72px 0 0;
font-size: 40px;
line-height: 1.18;
font-weight: 800;
letter-spacing: 0;
}
.login-hero-main p {
position: relative;
z-index: 1;
max-width: 560px;
margin: 18px 0 0;
color: rgba(255, 255, 255, 0.74);
font-size: 16px;
line-height: 1.75;
}
.login-highlights {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.login-highlights span {
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 6px;
color: rgba(255, 255, 255, 0.82);
font-size: 13px;
}
.login-card {
display: flex;
flex-direction: column;
justify-content: center;
padding: 48px;
}
.login-card-head {
margin-bottom: 28px;
}
.login-card h2 {
margin: 0;
color: #132822;
font-size: 28px;
line-height: 1.25;
letter-spacing: 0;
}
.login-card p {
margin: 8px 0 0;
color: #667a73;
line-height: 1.6;
}
.login-submit {
height: 44px;
margin-top: 4px;
font-weight: 700;
}
.login-box h1, .login-box h1,
.page-head h2 { .page-head h2 {
margin: 0; margin: 0;
@@ -809,9 +923,33 @@ textarea {
min-width: 0; min-width: 0;
} }
.login-box { .login-page {
width: min(380px, calc(100vw - 32px)); padding: 18px;
padding: 24px; }
.login-hero {
min-height: auto;
grid-template-columns: 1fr;
}
.login-hero-main {
padding: 28px;
}
.login-hero-main::after {
width: 160px;
height: 160px;
right: 18px;
bottom: 18px;
}
.login-hero-main h1 {
margin-top: 42px;
font-size: 28px;
}
.login-card {
padding: 28px;
} }
.admin-shell { .admin-shell {

View File

@@ -18,6 +18,12 @@ ACCESS_TOKEN_EXPIRE_MINUTES=43200
MOCK_SMS_ENABLED=true MOCK_SMS_ENABLED=true
MOCK_SMS_CODE=123456 MOCK_SMS_CODE=123456
SMS_CODE_EXPIRE_MINUTES=5 SMS_CODE_EXPIRE_MINUTES=5
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
MOCK_RAG_ENABLED=false MOCK_RAG_ENABLED=false
MOCK_MODEL_ENABLED=true MOCK_MODEL_ENABLED=true
FEISHU_MOCK_ENABLED=false FEISHU_MOCK_ENABLED=false

View File

@@ -39,6 +39,12 @@ class Settings(BaseSettings):
mock_sms_enabled: bool = True mock_sms_enabled: bool = True
mock_sms_code: str = "123456" mock_sms_code: str = "123456"
sms_code_expire_minutes: int = 5 sms_code_expire_minutes: int = 5
aliyun_sms_access_key_id: str = ""
aliyun_sms_access_key_secret: str = ""
aliyun_sms_sign_name: str = ""
aliyun_sms_template_code: str = ""
aliyun_sms_template_param_key: str = "code"
aliyun_sms_endpoint: str = "dysmsapi.aliyuncs.com"
mock_rag_enabled: bool = False mock_rag_enabled: bool = False
mock_model_enabled: bool = True mock_model_enabled: bool = True
feishu_mock_enabled: bool = False feishu_mock_enabled: bool = False

View File

@@ -18,11 +18,11 @@ class AuthService:
def send_sms_code(cls, db: Session, phone: str) -> None: def send_sms_code(cls, db: Session, phone: str) -> None:
user = cls._get_existing_user(db, phone) user = cls._get_existing_user(db, phone)
cls._ensure_user_can_login(user) cls._ensure_user_can_login(user)
SmsCodeService.send_code(phone) SmsCodeService.send_code(db, phone)
@classmethod @classmethod
def login_with_sms(cls, db: Session, phone: str, code: str) -> dict: def login_with_sms(cls, db: Session, phone: str, code: str) -> dict:
SmsCodeService.verify_code(phone, code) SmsCodeService.verify_code(db, phone, code)
user = cls._get_existing_user(db, phone) user = cls._get_existing_user(db, phone)
cls._ensure_user_can_login(user) cls._ensure_user_can_login(user)

View File

@@ -2,11 +2,19 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import json
import logging
import random
from re import fullmatch from re import fullmatch
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
from app.models.ai_config import SystemConfig
logger = logging.getLogger(__name__)
@dataclass @dataclass
@@ -15,26 +23,40 @@ class SmsCodeRecord:
expires_at: datetime expires_at: datetime
@dataclass(frozen=True)
class SmsProviderConfig:
mock_sms_enabled: bool
mock_sms_code: str
sms_code_expire_minutes: int
aliyun_sms_access_key_id: str
aliyun_sms_access_key_secret: str
aliyun_sms_sign_name: str
aliyun_sms_template_code: str
aliyun_sms_template_param_key: str
aliyun_sms_endpoint: str
class SmsCodeService: class SmsCodeService:
_codes: dict[str, SmsCodeRecord] = {} _codes: dict[str, SmsCodeRecord] = {}
@classmethod @classmethod
def send_code(cls, phone: str) -> None: def send_code(cls, db: Session, phone: str) -> None:
cls._validate_phone(phone) cls._validate_phone(phone)
settings = get_settings() config = _load_sms_config(db)
if not settings.mock_sms_enabled: code = config.mock_sms_code if config.mock_sms_enabled else _generate_sms_code()
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="真实短信服务尚未接入") if not config.mock_sms_enabled:
_send_aliyun_sms(phone, code, config)
cls._codes[phone] = SmsCodeRecord( cls._codes[phone] = SmsCodeRecord(
code=settings.mock_sms_code, code=code,
expires_at=datetime.now(UTC) + timedelta(minutes=settings.sms_code_expire_minutes), expires_at=datetime.now(UTC) + timedelta(minutes=config.sms_code_expire_minutes),
) )
@classmethod @classmethod
def verify_code(cls, phone: str, code: str) -> None: def verify_code(cls, db: Session, phone: str, code: str) -> None:
cls._validate_phone(phone) cls._validate_phone(phone)
record = cls._codes.get(phone) record = cls._codes.get(phone)
settings = get_settings() config = _load_sms_config(db)
if settings.mock_sms_enabled and record is None and code == settings.mock_sms_code: if config.mock_sms_enabled and record is None and code == config.mock_sms_code:
return return
if record is None: if record is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码不存在或已过期") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码不存在或已过期")
@@ -49,3 +71,135 @@ class SmsCodeService:
def _validate_phone(phone: str) -> None: def _validate_phone(phone: str) -> None:
if fullmatch(r"1[3-9]\d{9}", phone) is None: if fullmatch(r"1[3-9]\d{9}", phone) is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="手机号格式不正确") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="手机号格式不正确")
def _load_sms_config(db: Session) -> SmsProviderConfig:
settings = get_settings()
rows = db.scalars(select(SystemConfig)).all()
values = {row.config_key: row.config_value for row in rows}
return SmsProviderConfig(
mock_sms_enabled=_config_bool(values, "mock_sms_enabled", settings.mock_sms_enabled),
mock_sms_code=_config_text(values, "mock_sms_code", settings.mock_sms_code),
sms_code_expire_minutes=_config_int(
values,
"sms_code_expire_minutes",
settings.sms_code_expire_minutes,
minimum=1,
maximum=60,
),
aliyun_sms_access_key_id=_config_text(
values,
"aliyun_sms_access_key_id",
settings.aliyun_sms_access_key_id,
),
aliyun_sms_access_key_secret=_config_text(
values,
"aliyun_sms_access_key_secret",
settings.aliyun_sms_access_key_secret,
),
aliyun_sms_sign_name=_config_text(values, "aliyun_sms_sign_name", settings.aliyun_sms_sign_name),
aliyun_sms_template_code=_config_text(
values,
"aliyun_sms_template_code",
settings.aliyun_sms_template_code,
),
aliyun_sms_template_param_key=_config_text(
values,
"aliyun_sms_template_param_key",
settings.aliyun_sms_template_param_key,
)
or "code",
aliyun_sms_endpoint=_config_text(values, "aliyun_sms_endpoint", settings.aliyun_sms_endpoint)
or "dysmsapi.aliyuncs.com",
)
def _send_aliyun_sms(phone: str, code: str, config: SmsProviderConfig) -> None:
missing = [
name
for name, value in (
("AccessKey ID", config.aliyun_sms_access_key_id),
("AccessKey Secret", config.aliyun_sms_access_key_secret),
("短信签名", config.aliyun_sms_sign_name),
("模板 Code", config.aliyun_sms_template_code),
)
if not value
]
if missing:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"阿里云短信配置不完整:请补充{','.join(missing)}",
)
try:
from alibabacloud_dysmsapi20170525.client import Client as DysmsapiClient
from alibabacloud_dysmsapi20170525 import models as dysms_models
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_tea_util import models as util_models
except ImportError as exc:
logger.exception("Aliyun SMS SDK import failed")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="阿里云短信 SDK 未安装,请联系管理员检查部署依赖",
) from exc
client_config = open_api_models.Config(
access_key_id=config.aliyun_sms_access_key_id,
access_key_secret=config.aliyun_sms_access_key_secret,
)
client_config.endpoint = config.aliyun_sms_endpoint
request = dysms_models.SendSmsRequest(
phone_numbers=phone,
sign_name=config.aliyun_sms_sign_name,
template_code=config.aliyun_sms_template_code,
template_param=json.dumps({config.aliyun_sms_template_param_key: code}, ensure_ascii=False),
)
try:
response = DysmsapiClient(client_config).send_sms_with_options(request, util_models.RuntimeOptions())
except Exception as exc:
logger.warning("Aliyun SMS send failed for %s: %s", _mask_phone(phone), exc.__class__.__name__)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="短信发送失败,请稍后再试",
) from exc
body = getattr(response, "body", None)
response_code = getattr(body, "code", None)
if response_code not in (None, "OK"):
logger.warning("Aliyun SMS rejected for %s: %s", _mask_phone(phone), response_code)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="短信发送失败,请稍后再试",
)
def _generate_sms_code() -> str:
return f"{random.SystemRandom().randint(0, 999999):06d}"
def _config_text(values: dict[str, str], key: str, default: str) -> str:
value = values.get(key)
return value.strip() if value is not None else default
def _config_bool(values: dict[str, str], key: str, default: bool) -> bool:
value = values.get(key)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _config_int(values: dict[str, str], key: str, default: int, *, minimum: int, maximum: int) -> int:
value = values.get(key)
if value is not None:
try:
return max(minimum, min(maximum, int(float(value.strip()))))
except ValueError:
pass
return default
def _mask_phone(phone: str) -> str:
if len(phone) != 11:
return "***"
return f"{phone[:3]}****{phone[-4:]}"

View File

@@ -12,3 +12,4 @@ httpx>=0.28.0
redis>=5.0.0 redis>=5.0.0
openpyxl>=3.1.5 openpyxl>=3.1.5
python-multipart>=0.0.20 python-multipart>=0.0.20
alibabacloud_dysmsapi20170525>=4.1.2