feat(distribution): 支持分站独立短信验证码通道

- 新增分销站点独立短信服务商配置(阿里云),登录验证码按 site_code/domain
  解析专属通道,未启用或解析失败时回退主站默认通道
- 密钥经 crypto.Encryptor 加密落库,模型字段 json 序列化隔离,DTO 仅回传掩码
- provider 白名单校验,mock 仅在非生产环境放行;启用时强制校验必填凭据
- 配置读写/测试接口限管理员权限,测试发送增加 60s 频控(Redis,缺省进程内)
- 解密失败记告警并优雅回退主站,避免把密文当明文密钥使用
- 两套前端新增短信配置入口、必填与手机号校验、发送冷却
- 新增迁移 000071 及加解密、白名单、频控、解析回退等单测
This commit is contained in:
yml2213
2026-09-16 14:06:01 +08:00
parent aef858f399
commit dc13b5dc82
16 changed files with 1971 additions and 14 deletions
+47
View File
@@ -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}>