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
@@ -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>