feat(distribution): 支持分站独立短信验证码通道
- 新增分销站点独立短信服务商配置(阿里云),登录验证码按 site_code/domain 解析专属通道,未启用或解析失败时回退主站默认通道 - 密钥经 crypto.Encryptor 加密落库,模型字段 json 序列化隔离,DTO 仅回传掩码 - provider 白名单校验,mock 仅在非生产环境放行;启用时强制校验必填凭据 - 配置读写/测试接口限管理员权限,测试发送增加 60s 频控(Redis,缺省进程内) - 解密失败记告警并优雅回退主站,避免把密文当明文密钥使用 - 两套前端新增短信配置入口、必填与手机号校验、发送冷却 - 新增迁移 000071 及加解密、白名单、频控、解析回退等单测
This commit is contained in:
@@ -301,3 +301,64 @@ export async function syncDistributionSiteAccounts(siteId: number, req?: SyncSit
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export interface DistributionSiteSMSConfig {
|
||||
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 UpdateDistributionSiteSMSConfigPayload {
|
||||
enabled?: boolean
|
||||
provider?: string
|
||||
access_key_id?: string
|
||||
access_key_secret?: string
|
||||
endpoint?: string
|
||||
sign_name?: string
|
||||
login_template_code?: string
|
||||
}
|
||||
|
||||
export interface TestDistributionSiteSMSConfigPayload {
|
||||
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 fetchDistributionSiteSMSConfig(siteId: number) {
|
||||
const { data } = await apiClient.get<ApiResponse<DistributionSiteSMSConfig>>(
|
||||
`/admin/distribution/sites/${siteId}/sms-config`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateDistributionSiteSMSConfig(
|
||||
siteId: number,
|
||||
payload: UpdateDistributionSiteSMSConfigPayload
|
||||
) {
|
||||
const { data } = await apiClient.put<ApiResponse<DistributionSiteSMSConfig>>(
|
||||
`/admin/distribution/sites/${siteId}/sms-config`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function testDistributionSiteSMSConfig(
|
||||
siteId: number,
|
||||
payload: TestDistributionSiteSMSConfigPayload
|
||||
) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ message: string }>>(
|
||||
`/admin/distribution/sites/${siteId}/sms-config/test`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import {
|
||||
Plus,
|
||||
Shop,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Picture,
|
||||
Upload,
|
||||
Coin,
|
||||
Message,
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchDistributionSites,
|
||||
@@ -18,6 +19,9 @@ import {
|
||||
onboardDistributionSite,
|
||||
syncDistributionSiteAccounts,
|
||||
updateDistributionSite,
|
||||
fetchDistributionSiteSMSConfig,
|
||||
updateDistributionSiteSMSConfig,
|
||||
testDistributionSiteSMSConfig,
|
||||
type DistributionSite,
|
||||
type OnboardDistributionSitePayload,
|
||||
type OnboardDistributionSiteResult,
|
||||
@@ -63,6 +67,45 @@ const qrForm = reactive({
|
||||
listing_qrcode_url: '',
|
||||
})
|
||||
|
||||
const smsDialogVisible = ref(false)
|
||||
const smsLoading = ref(false)
|
||||
const smsSubmitting = ref(false)
|
||||
const smsTesting = ref(false)
|
||||
const smsTestCooldown = ref(0)
|
||||
let smsCooldownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startSmsCooldown(seconds = 60) {
|
||||
smsTestCooldown.value = seconds
|
||||
if (smsCooldownTimer) clearInterval(smsCooldownTimer)
|
||||
smsCooldownTimer = setInterval(() => {
|
||||
if (smsTestCooldown.value > 0) {
|
||||
smsTestCooldown.value--
|
||||
} else if (smsCooldownTimer) {
|
||||
clearInterval(smsCooldownTimer)
|
||||
smsCooldownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (smsCooldownTimer) clearInterval(smsCooldownTimer)
|
||||
})
|
||||
|
||||
const smsSecretInput = ref('')
|
||||
const smsTestPhone = ref('')
|
||||
const smsForm = reactive({
|
||||
site_id: 0,
|
||||
site_name: '',
|
||||
enabled: false,
|
||||
provider: 'aliyun',
|
||||
sign_name: '',
|
||||
login_template_code: '',
|
||||
access_key_id: '',
|
||||
masked_secret: '',
|
||||
has_secret: false,
|
||||
endpoint: '',
|
||||
})
|
||||
|
||||
function handleOpenSettlement(row: DistributionSite) {
|
||||
settlementForm.site_id = row.id
|
||||
settlementForm.site_name = row.site_name
|
||||
@@ -154,6 +197,143 @@ async function handleQrcodeSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenSMS(row: DistributionSite) {
|
||||
smsForm.site_id = row.id
|
||||
smsForm.site_name = row.site_name
|
||||
smsSecretInput.value = ''
|
||||
smsTestPhone.value = ''
|
||||
smsDialogVisible.value = true
|
||||
smsLoading.value = true
|
||||
try {
|
||||
const cfg = await fetchDistributionSiteSMSConfig(row.id)
|
||||
smsForm.enabled = cfg.enabled
|
||||
smsForm.provider = cfg.provider || 'aliyun'
|
||||
smsForm.sign_name = cfg.sign_name || ''
|
||||
smsForm.login_template_code = cfg.login_template_code || ''
|
||||
smsForm.access_key_id = cfg.access_key_id || ''
|
||||
smsForm.masked_secret = cfg.masked_secret || ''
|
||||
smsForm.has_secret = cfg.has_secret
|
||||
smsForm.endpoint = cfg.endpoint || ''
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '读取分站短信配置失败'))
|
||||
} finally {
|
||||
smsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSMSSubmit() {
|
||||
if (smsForm.enabled) {
|
||||
if (!smsForm.sign_name.trim()) {
|
||||
ElMessage.warning('启用独立短信时,短信签名不能为空')
|
||||
return
|
||||
}
|
||||
if (!smsForm.login_template_code.trim()) {
|
||||
ElMessage.warning('启用独立短信时,登录验证码模板 Code 不能为空')
|
||||
return
|
||||
}
|
||||
if (!smsForm.access_key_id.trim()) {
|
||||
ElMessage.warning('启用独立短信时,AccessKey ID 不能为空')
|
||||
return
|
||||
}
|
||||
if (!smsForm.has_secret && !smsSecretInput.value.trim()) {
|
||||
ElMessage.warning('启用独立短信时,AccessKey Secret 不能为空')
|
||||
return
|
||||
}
|
||||
}
|
||||
smsSubmitting.value = true
|
||||
try {
|
||||
const payload: {
|
||||
enabled: boolean
|
||||
provider: string
|
||||
sign_name: string
|
||||
login_template_code: string
|
||||
access_key_id: string
|
||||
access_key_secret?: string
|
||||
endpoint?: string
|
||||
} = {
|
||||
enabled: smsForm.enabled,
|
||||
provider: smsForm.provider || 'aliyun',
|
||||
sign_name: smsForm.sign_name.trim(),
|
||||
login_template_code: smsForm.login_template_code.trim(),
|
||||
access_key_id: smsForm.access_key_id.trim(),
|
||||
endpoint: smsForm.endpoint.trim(),
|
||||
}
|
||||
if (smsSecretInput.value.trim()) {
|
||||
payload.access_key_secret = smsSecretInput.value.trim()
|
||||
}
|
||||
await updateDistributionSiteSMSConfig(smsForm.site_id, payload)
|
||||
ElMessage.success(`【${smsForm.site_name}】短信配置已保存`)
|
||||
smsDialogVisible.value = false
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '保存短信配置失败'))
|
||||
} finally {
|
||||
smsSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSMSTest() {
|
||||
const phone = smsTestPhone.value.trim()
|
||||
if (!phone) {
|
||||
ElMessage.warning('请输入接收测试短信的手机号')
|
||||
return
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
ElMessage.warning('手机号格式不正确,请输入有效的11位手机号码')
|
||||
return
|
||||
}
|
||||
if (!smsForm.access_key_id.trim()) {
|
||||
ElMessage.warning('请先填写 AccessKey ID')
|
||||
return
|
||||
}
|
||||
if (!smsForm.has_secret && !smsSecretInput.value.trim()) {
|
||||
ElMessage.warning('请先填写 AccessKey Secret')
|
||||
return
|
||||
}
|
||||
if (!smsForm.sign_name.trim()) {
|
||||
ElMessage.warning('请先填写短信签名')
|
||||
return
|
||||
}
|
||||
if (!smsForm.login_template_code.trim()) {
|
||||
ElMessage.warning('请先填写验证码模板 Code')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将向手机号 ${phone} 发送一条测试验证码,并消耗该站点的阿里云短信额度,是否继续?`,
|
||||
'确认发送测试短信',
|
||||
{
|
||||
confirmButtonText: '确认发送',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info',
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
smsTesting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
phone,
|
||||
enabled: true,
|
||||
provider: smsForm.provider || 'aliyun',
|
||||
sign_name: smsForm.sign_name.trim(),
|
||||
login_template_code: smsForm.login_template_code.trim(),
|
||||
access_key_id: smsForm.access_key_id.trim(),
|
||||
endpoint: smsForm.endpoint.trim() || undefined,
|
||||
access_key_secret: smsSecretInput.value.trim() || undefined,
|
||||
}
|
||||
await testDistributionSiteSMSConfig(smsForm.site_id, payload)
|
||||
ElMessage.success('测试短信发送成功,请查收手机短信')
|
||||
startSmsCooldown(60)
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '测试短信发送失败'))
|
||||
} finally {
|
||||
smsTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onboardForm = reactive({
|
||||
site_name: '',
|
||||
site_code: '',
|
||||
@@ -377,7 +557,7 @@ onMounted(() => {
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="340" fixed="right">
|
||||
<el-table-column label="操作" width="410" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
@@ -397,6 +577,9 @@ onMounted(() => {
|
||||
<el-button type="primary" link :icon="Picture" @click="handleOpenQrcode(row)">
|
||||
二维码
|
||||
</el-button>
|
||||
<el-button type="primary" link :icon="Message" @click="handleOpenSMS(row)">
|
||||
短信配置
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -663,6 +846,82 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 短信配置对话框 -->
|
||||
<el-dialog
|
||||
v-model="smsDialogVisible"
|
||||
title="分销站点独立短信服务配置"
|
||||
width="620px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="smsLoading">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="启用后,该分站用户的登录注册验证码将使用独立的签名与模板,并直接计入该分站的阿里云短信额度;未启用时自动回退使用主站默认短信通道。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<el-form label-width="130px">
|
||||
<el-form-item label="分销站点">
|
||||
<el-input :model-value="smsForm.site_name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用独立短信">
|
||||
<el-switch v-model="smsForm.enabled" />
|
||||
<span style="margin-left: 12px; font-size: 13px; color: #6b7280">
|
||||
{{ smsForm.enabled ? '已启用(优先使用分站凭据)' : '未启用(回退使用主站额度)' }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="短信服务商">
|
||||
<el-select v-model="smsForm.provider" style="width: 100%">
|
||||
<el-option label="阿里云短信服务 (Aliyun Dysms)" value="aliyun" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="短信签名" :required="smsForm.enabled">
|
||||
<el-input v-model="smsForm.sign_name" placeholder="例如:XX租号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码模板 Code" :required="smsForm.enabled">
|
||||
<el-input v-model="smsForm.login_template_code" placeholder="例如:SMS_123456789" />
|
||||
</el-form-item>
|
||||
<el-form-item label="AccessKey ID" :required="smsForm.enabled">
|
||||
<el-input v-model="smsForm.access_key_id" placeholder="例如:LTAI5t..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="AccessKey Secret" :required="smsForm.enabled && !smsForm.has_secret">
|
||||
<el-input
|
||||
v-model="smsSecretInput"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="
|
||||
smsForm.has_secret
|
||||
? `已设置密钥 (${smsForm.masked_secret}),留空保持不变`
|
||||
: '请输入 AccessKey Secret'
|
||||
"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="API Endpoint">
|
||||
<el-input v-model="smsForm.endpoint" placeholder="默认 dysmsapi.aliyuncs.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="连通性测试">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input v-model="smsTestPhone" placeholder="输入接收测试短信的手机号" />
|
||||
<el-button
|
||||
:loading="smsTesting"
|
||||
:disabled="smsTestCooldown > 0"
|
||||
@click="handleSMSTest"
|
||||
>
|
||||
{{ smsTestCooldown > 0 ? `重新发送(${smsTestCooldown}s)` : '发送测试' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="smsDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="smsSubmitting" @click="handleSMSSubmit">
|
||||
保存设置
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user