feat(distribution): 支持分站独立短信验证码通道
- 新增分销站点独立短信服务商配置(阿里云),登录验证码按 site_code/domain 解析专属通道,未启用或解析失败时回退主站默认通道 - 密钥经 crypto.Encryptor 加密落库,模型字段 json 序列化隔离,DTO 仅回传掩码 - provider 白名单校验,mock 仅在非生产环境放行;启用时强制校验必填凭据 - 配置读写/测试接口限管理员权限,测试发送增加 60s 频控(Redis,缺省进程内) - 解密失败记告警并优雅回退主站,避免把密文当明文密钥使用 - 两套前端新增短信配置入口、必填与手机号校验、发送冷却 - 新增迁移 000071 及加解密、白名单、频控、解析回退等单测
This commit is contained in:
@@ -692,3 +692,50 @@ export async function fetchDashboardAnalytics(siteID: number): Promise<Distribut
|
||||
const resp = await api.get<ApiResponse<DistributionAnalytics>>(`/admin/distribution/sites/${siteID}/analytics`)
|
||||
return extract(resp)
|
||||
}
|
||||
|
||||
export interface SiteSMSConfig {
|
||||
enabled: boolean
|
||||
provider: string
|
||||
access_key_id: string
|
||||
masked_secret: string
|
||||
has_secret: boolean
|
||||
endpoint: string
|
||||
sign_name: string
|
||||
login_template_code: string
|
||||
}
|
||||
|
||||
export interface UpdateSiteSMSConfigPayload {
|
||||
enabled?: boolean
|
||||
provider?: string
|
||||
access_key_id?: string
|
||||
access_key_secret?: string
|
||||
endpoint?: string
|
||||
sign_name?: string
|
||||
login_template_code?: string
|
||||
}
|
||||
|
||||
export interface TestSiteSMSConfigPayload {
|
||||
phone: string
|
||||
enabled?: boolean
|
||||
provider?: string
|
||||
access_key_id?: string
|
||||
access_key_secret?: string
|
||||
endpoint?: string
|
||||
sign_name?: string
|
||||
login_template_code?: string
|
||||
}
|
||||
|
||||
export async function fetchSiteSMSConfig(siteID: number): Promise<SiteSMSConfig> {
|
||||
const resp = await api.get<ApiResponse<SiteSMSConfig>>(`/admin/distribution/sites/${siteID}/sms-config`)
|
||||
return extract(resp)
|
||||
}
|
||||
|
||||
export async function updateSiteSMSConfig(siteID: number, payload: UpdateSiteSMSConfigPayload): Promise<SiteSMSConfig> {
|
||||
const resp = await api.put<ApiResponse<SiteSMSConfig>>(`/admin/distribution/sites/${siteID}/sms-config`, payload)
|
||||
return extract(resp)
|
||||
}
|
||||
|
||||
export async function testSiteSMSConfig(siteID: number, payload: TestSiteSMSConfigPayload): Promise<{ message: string }> {
|
||||
const resp = await api.post<ApiResponse<{ message: string }>>(`/admin/distribution/sites/${siteID}/sms-config/test`, payload)
|
||||
return extract(resp)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
Tag,
|
||||
Tabs,
|
||||
Collapse,
|
||||
Switch,
|
||||
Alert,
|
||||
Popconfirm,
|
||||
} from 'antd'
|
||||
import {
|
||||
SaveOutlined,
|
||||
@@ -27,14 +30,22 @@ import {
|
||||
InfoCircleOutlined,
|
||||
SkinOutlined,
|
||||
GlobalOutlined,
|
||||
MessageOutlined,
|
||||
SendOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
listSites,
|
||||
updateSiteBranding,
|
||||
updateSite,
|
||||
getMe,
|
||||
fetchSiteSMSConfig,
|
||||
updateSiteSMSConfig,
|
||||
testSiteSMSConfig,
|
||||
type Site,
|
||||
type SiteBrandingPayload,
|
||||
type SiteSMSConfig,
|
||||
type UpdateSiteSMSConfigPayload,
|
||||
type TestSiteSMSConfigPayload,
|
||||
} from '../api'
|
||||
import { useMember } from '../MemberContext'
|
||||
|
||||
@@ -76,6 +87,31 @@ export default function SiteSettings() {
|
||||
const [receiveRatioDelta, setReceiveRatioDelta] = useState(0)
|
||||
const [savingRatio, setSavingRatio] = useState(false)
|
||||
|
||||
// SMS config state
|
||||
const [smsConfig, setSmsConfig] = useState<SiteSMSConfig>({
|
||||
enabled: false,
|
||||
provider: 'aliyun',
|
||||
access_key_id: '',
|
||||
masked_secret: '',
|
||||
has_secret: false,
|
||||
endpoint: 'dysmsapi.aliyuncs.com',
|
||||
sign_name: '',
|
||||
login_template_code: '',
|
||||
})
|
||||
const [smsSecretInput, setSmsSecretInput] = useState('')
|
||||
const [savingSMS, setSavingSMS] = useState(false)
|
||||
const [testPhone, setTestPhone] = useState('')
|
||||
const [testingSMS, setTestingSMS] = useState(false)
|
||||
const [testCooldown, setTestCooldown] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (testCooldown <= 0) return
|
||||
const timer = setInterval(() => {
|
||||
setTestCooldown((prev) => (prev > 0 ? prev - 1 : 0))
|
||||
}, 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [testCooldown])
|
||||
|
||||
const loadSite = useCallback(async (id: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -105,6 +141,14 @@ export default function SiteSettings() {
|
||||
setFooterInfo({})
|
||||
}
|
||||
setReceiveRatioDelta(Math.max(0, found.receive_ratio_delta || 0))
|
||||
|
||||
try {
|
||||
const sms = await fetchSiteSMSConfig(id)
|
||||
setSmsConfig(sms)
|
||||
setSmsSecretInput('')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.error('加载站点设置失败')
|
||||
@@ -202,6 +246,109 @@ export default function SiteSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSMS() {
|
||||
if (!siteID) return
|
||||
if (smsConfig.enabled) {
|
||||
if (!smsConfig.access_key_id?.trim()) {
|
||||
message.warning('启用独立短信时,阿里云 AccessKey ID 不能为空')
|
||||
return
|
||||
}
|
||||
if (!smsConfig.has_secret && !smsSecretInput.trim()) {
|
||||
message.warning('启用独立短信时,阿里云 AccessKey Secret 不能为空')
|
||||
return
|
||||
}
|
||||
if (!smsConfig.sign_name?.trim()) {
|
||||
message.warning('启用独立短信时,短信签名不能为空')
|
||||
return
|
||||
}
|
||||
if (!smsConfig.login_template_code?.trim()) {
|
||||
message.warning('启用独立短信时,登录验证码模板 Code 不能为空')
|
||||
return
|
||||
}
|
||||
}
|
||||
setSavingSMS(true)
|
||||
try {
|
||||
const payload: UpdateSiteSMSConfigPayload = {
|
||||
enabled: smsConfig.enabled,
|
||||
provider: smsConfig.provider || 'aliyun',
|
||||
access_key_id: smsConfig.access_key_id.trim(),
|
||||
endpoint: smsConfig.endpoint.trim() || 'dysmsapi.aliyuncs.com',
|
||||
sign_name: smsConfig.sign_name.trim(),
|
||||
login_template_code: smsConfig.login_template_code.trim(),
|
||||
}
|
||||
if (smsSecretInput.trim()) {
|
||||
payload.access_key_secret = smsSecretInput.trim()
|
||||
}
|
||||
const updated = await updateSiteSMSConfig(siteID, payload)
|
||||
setSmsConfig(updated)
|
||||
setSmsSecretInput('')
|
||||
message.success('短信服务配置保存成功')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'response' in err
|
||||
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
|
||||
: '保存短信配置失败'
|
||||
message.error(msg || '保存短信配置失败')
|
||||
} finally {
|
||||
setSavingSMS(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestSMS() {
|
||||
if (!siteID) return
|
||||
const phone = testPhone.trim()
|
||||
if (!phone) {
|
||||
message.warning('请输入接收测试短信的手机号')
|
||||
return
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
message.warning('手机号格式不正确,请输入有效的11位手机号码')
|
||||
return
|
||||
}
|
||||
if (!smsConfig.access_key_id?.trim()) {
|
||||
message.warning('请先填写阿里云 AccessKey ID')
|
||||
return
|
||||
}
|
||||
if (!smsConfig.has_secret && !smsSecretInput.trim()) {
|
||||
message.warning('请先填写阿里云 AccessKey Secret')
|
||||
return
|
||||
}
|
||||
if (!smsConfig.sign_name?.trim()) {
|
||||
message.warning('请先填写短信签名')
|
||||
return
|
||||
}
|
||||
if (!smsConfig.login_template_code?.trim()) {
|
||||
message.warning('请先填写登录验证码模板 Code')
|
||||
return
|
||||
}
|
||||
setTestingSMS(true)
|
||||
try {
|
||||
const payload: TestSiteSMSConfigPayload = {
|
||||
phone,
|
||||
enabled: true,
|
||||
provider: smsConfig.provider || 'aliyun',
|
||||
access_key_id: smsConfig.access_key_id.trim(),
|
||||
endpoint: smsConfig.endpoint.trim() || 'dysmsapi.aliyuncs.com',
|
||||
sign_name: smsConfig.sign_name.trim(),
|
||||
login_template_code: smsConfig.login_template_code.trim(),
|
||||
}
|
||||
if (smsSecretInput.trim()) {
|
||||
payload.access_key_secret = smsSecretInput.trim()
|
||||
}
|
||||
await testSiteSMSConfig(siteID, payload)
|
||||
message.success('测试短信发送成功,请查收手机短信')
|
||||
setTestCooldown(60)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'response' in err
|
||||
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
|
||||
: '测试短信发送失败'
|
||||
message.error(msg || '测试短信发送失败')
|
||||
} finally {
|
||||
setTestingSMS(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!siteID) return
|
||||
setSaving(true)
|
||||
@@ -299,6 +446,153 @@ export default function SiteSettings() {
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card loading={loading}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<div>
|
||||
<Space align="center">
|
||||
<MessageOutlined style={{ fontSize: 18, color: '#1677ff' }} />
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
独立短信验证码配置
|
||||
</Typography.Title>
|
||||
</Space>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 4, marginBottom: 0 }}>
|
||||
开启后,本分站访客登录/注册时将调用分站专属的阿里云短信模板与签名,并直接计入本站自己的阿里云短信额度;未开启时自动回退使用主站默认短信通道。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type={smsConfig.enabled ? 'info' : 'warning'}
|
||||
showIcon
|
||||
message={
|
||||
smsConfig.enabled
|
||||
? '当前已启用独立短信服务:分站登录验证码将消耗您配置的阿里云额度,展示您的专属短信签名。'
|
||||
: '当前未启用独立短信服务:分站将免费使用主站平台额度发送验证码(展示主站默认签名)。'
|
||||
}
|
||||
/>
|
||||
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item label="启用独立短信服务">
|
||||
<Space>
|
||||
<Switch
|
||||
checked={smsConfig.enabled}
|
||||
onChange={(val) => setSmsConfig({ ...smsConfig, enabled: val })}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{smsConfig.enabled ? '已开启(优先使用分站凭据)' : '已停用(回退使用主站额度)'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item label="短信服务商">
|
||||
<Select
|
||||
value={smsConfig.provider || 'aliyun'}
|
||||
onChange={(val) => setSmsConfig({ ...smsConfig, provider: val })}
|
||||
options={[{ label: '阿里云短信服务 (Aliyun Dysms)', value: 'aliyun' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item label="短信签名 (SignName)" required={smsConfig.enabled} tooltip="在阿里云短信控制台申请并已通过审核的签名,例如:【XX租号】">
|
||||
<Input
|
||||
placeholder="例如:XX租号"
|
||||
value={smsConfig.sign_name}
|
||||
onChange={(e) => setSmsConfig({ ...smsConfig, sign_name: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item label="验证码模板 Code (TemplateCode)" required={smsConfig.enabled} tooltip="在阿里云申请的验证码模板,必须包含 ${code} 变量,例如:SMS_123456789">
|
||||
<Input
|
||||
placeholder="例如:SMS_123456789"
|
||||
value={smsConfig.login_template_code}
|
||||
onChange={(e) => setSmsConfig({ ...smsConfig, login_template_code: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item label="阿里云 AccessKey ID" required={smsConfig.enabled} tooltip="具备 Dysms 权限的阿里云 RAM 用户 AccessKey ID">
|
||||
<Input
|
||||
placeholder="例如:LTAI5t..."
|
||||
value={smsConfig.access_key_id}
|
||||
onChange={(e) => setSmsConfig({ ...smsConfig, access_key_id: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
label="阿里云 AccessKey Secret"
|
||||
required={smsConfig.enabled && !smsConfig.has_secret}
|
||||
tooltip={smsConfig.has_secret ? `已保存密钥 (${smsConfig.masked_secret}),如需更换请输入新密钥,留空则保持不变` : '请输入阿里云 AccessKey Secret'}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={smsConfig.has_secret ? '已设置密钥,留空保持不变' : '请输入 AccessKey Secret'}
|
||||
value={smsSecretInput}
|
||||
onChange={(e) => setSmsSecretInput(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item label="API Endpoint (可选)" tooltip="默认 dysmsapi.aliyuncs.com">
|
||||
<Input
|
||||
placeholder="dysmsapi.aliyuncs.com"
|
||||
value={smsConfig.endpoint}
|
||||
onChange={(e) => setSmsConfig({ ...smsConfig, endpoint: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item label="连通性测试">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="输入手机号测试接收"
|
||||
value={testPhone}
|
||||
onChange={(e) => setTestPhone(e.target.value)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确认发送测试短信?"
|
||||
description="将向填写的手机号发送一条随机验证码,并消耗该站点的阿里云短信额度。"
|
||||
okText="确认发送"
|
||||
cancelText="取消"
|
||||
disabled={testingSMS || testCooldown > 0}
|
||||
onConfirm={() => void handleTestSMS()}
|
||||
>
|
||||
<Button
|
||||
icon={<SendOutlined />}
|
||||
loading={testingSMS}
|
||||
disabled={testCooldown > 0}
|
||||
>
|
||||
{testCooldown > 0 ? `重新发送(${testCooldown}s)` : '发送测试'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={savingSMS}
|
||||
onClick={() => void handleSaveSMS()}
|
||||
>
|
||||
保存短信配置
|
||||
</Button>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Row gutter={24}>
|
||||
{/* Left Visual Form Customizer */}
|
||||
<Col xs={24} lg={14}>
|
||||
|
||||
Reference in New Issue
Block a user