diff --git a/assets/admin/src/api/modules/users.js b/assets/admin/src/api/modules/users.js index 3fc85192..39fa3c68 100644 --- a/assets/admin/src/api/modules/users.js +++ b/assets/admin/src/api/modules/users.js @@ -5,6 +5,7 @@ export const usersApiMethods = { getAuthSettings() { return this.request('/settings/auth') }, saveAuthSettings(data) { return this.request('/settings/auth', { method: 'POST', body: JSON.stringify(data) }) }, + testSms(phone) { return this.request('/sms/test', { method: 'POST', body: JSON.stringify({ phone }) }) }, getNotificationSettings() { return this.request('/settings/notifications') }, saveNotificationSettings(data) { return this.request('/settings/notifications', { method: 'POST', body: JSON.stringify(data) }) }, diff --git a/assets/admin/src/pages/SchemaPage.vue b/assets/admin/src/pages/SchemaPage.vue index 13415f55..dd8a3e7e 100644 --- a/assets/admin/src/pages/SchemaPage.vue +++ b/assets/admin/src/pages/SchemaPage.vue @@ -6,7 +6,7 @@ :icon="pageMeta.icon" :schema="pageMeta.groups" :form-data="formData" - :dirty="dirty" + :dirty="isSmsPage ? isSmsSettingsDirty : dirty" :loading="loading" :saving="saving" :load-error="loadError" @@ -131,6 +131,7 @@ const showTestModal = ref(false) const testLoading = ref(false) const testResult = ref(null) const serverSchema = ref(null) +const smsSavedSettings = ref('') const captchaSecretsToClear = ref(new Set()) const captchaProviders = [ { key: 'tencent', label: '腾讯云 Captcha', component: TencentConfig }, @@ -144,8 +145,17 @@ const captchaProviderTab = ref('tencent') const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path))) const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path)) const isEmailPage = computed(() => resolvedDomain.value === 'global' && resolvedSection.value === 'email') +const isSmsPage = computed(() => resolvedDomain.value === 'users' && resolvedSection.value === 'sms') const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login') const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation') +const isSmsSettingsDirty = computed(() => smsSavedSettings.value !== '' && smsSavedSettings.value !== smsSettingsSnapshot(formData.value)) + +function smsSettingsSnapshot(data) { + const settings = { ...(data || {}) } + delete settings.sms_test_phone + delete settings.sms_test_send + return JSON.stringify(settings) +} function resolveDomainFromPath(path) { const parts = path.split('/').filter(Boolean) @@ -216,6 +226,10 @@ const { delete payload[key] } }) + if (isSmsPage.value) { + delete payload.sms_test_phone + delete payload.sms_test_send + } if (clearSecrets.length) payload.__clear_secrets = clearSecrets if (isPermissionsLogin.value && captchaSecretsToClear.value.size) { payload.__clear_secrets = [...(payload.__clear_secrets || []), ...captchaSecretsToClear.value] @@ -226,12 +240,34 @@ const { return response }, defaults: defaults.value, + onLoaded: (data) => { + if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(data) + }, onSaved: () => { + if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(formData.value) message.success('设置已保存') }, }) async function handleFieldAction(key) { + if (key === 'sms_test_send' && isSmsPage.value) { + const phone = String(formData.value?.sms_test_phone || '').trim() + if (!phone) { + message.warning('请先填写测试手机号') + return + } + if (isSmsSettingsDirty.value) { + message.warning('请先保存当前配置,再发送测试短信') + return + } + try { + const response = await api.testSms(phone) + message.success(response?.message || '测试短信已发送') + } catch (error) { + message.error(error?.message || '测试短信发送失败') + } + return + } if (key !== 'email_test_send' || !isEmailPage.value) return const to = String(formData.value?.email_test_to || '').trim() if (!to) { diff --git a/assets/admin/src/schema/sections/users/sms.js b/assets/admin/src/schema/sections/users/sms.js index 9e910233..4d9d1f39 100644 --- a/assets/admin/src/schema/sections/users/sms.js +++ b/assets/admin/src/schema/sections/users/sms.js @@ -2,143 +2,98 @@ export const smsSchema = { key: 'users.sms', title: '短信接口', icon: 'fa fas fa-comment', - description: '配置短信验证码和短信通知服务的供应商、模板和限流策略。', + description: '设置手机验证码服务。验证码为 6 位数字,有效期 30 分钟。', defaults: { - sms_enabled: false, - sms_provider: 'aliyun', + sms_sdk: 'null', sms_number_limit_ch: true, - sms_access_key: '', - sms_access_secret: '', - sms_sign_name: '', - sms_template_code: '', - sms_code_length: 6, - sms_code_expire: 300, - sms_rate_limit: 60, - sms_daily_limit: 10, - sms_notify_bind: true, - sms_notify_login: false, - sms_notify_register: true, - sms_notify_reset_password: true, sms_ali_api_type: 'sms', + sms_ali_keyid: '', + sms_ali_keysecret: '', + sms_ali_sign_name: '', + sms_ali_template_code: '', sms_region_id: 'cn-hangzhou', sms_tencent_app_id: '', sms_tencent_app_key: '', - sms_tencent_secret_id: '', - sms_tencent_secret_key: '', - sms_tencent_region: 'ap-guangzhou', - sms_huawei_sender: '', - sms_huawei_signature: '', - sms_huawei_endpoint: '', - sms_custom_url: '', - sms_custom_method: 'POST', - sms_custom_auth_type: 'header', - sms_custom_auth_key: '', - sms_custom_auth_value: '', - sms_custom_headers: '', - sms_custom_body: '', - sms_custom_success_rule: '', + sms_tencent_sign_name: '', + sms_tencent_template_id: '', + sms_smsbao_username: '', + sms_smsbao_password: '', + sms_smsbao_api_key: '', + sms_smsbao_template: '', }, groups: [ { key: 'provider', - title: '供应商配置', + title: '短信接口', icon: 'fa fas fa-plug', fields: [ - { key: 'sms_enabled', label: '启用短信服务', type: 'switch', default: false, help: '开启后支持短信验证码和短信通知' }, { - key: 'sms_provider', - label: '短信供应商', + key: 'sms_sdk', + label: '设置短信接口', type: 'select', - default: 'aliyun', + default: 'null', options: [ - { label: '阿里云短信', value: 'aliyun' }, + { label: '不使用', value: 'null' }, + { label: '阿里云短信', value: 'ali' }, { label: '腾讯云短信', value: 'tencent' }, - { label: '华为云短信', value: 'huawei' }, - { label: '自定义接口', value: 'custom' }, + { label: '短信宝', value: 'smsbao' }, ], - dependsOn: { key: 'sms_enabled', value: true }, }, - { key: 'sms_number_limit_ch', label: '仅发国内', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true }, help: '开启后仅支持发送国内短信,关闭后可发送港澳台及国际短信' }, - { key: 'sms_access_key', label: 'AccessKey / SecretId / AppKey', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '阿里云填 AccessKey,腾讯云填 SecretId,华为云填 AppKey' }, - { key: 'sms_access_secret', label: 'AccessSecret / SecretKey / AppSecret', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_enabled', value: true } }, + { + key: 'sms_number_limit_ch', + label: '限制国内手机号', + type: 'switch', + default: true, + help: '开启后只允许发送到中国大陆手机号。', + }, + { key: 'sms_test_phone', label: '测试手机号', type: 'input', default: '', ui_only: true, help: '保存接口配置后,发送固定验证码 888888 测试短信。' }, + { key: 'sms_test_send', label: '发送测试短信', type: 'button', buttonType: 'primary', buttonText: '发送测试短信', ui_only: true }, ], }, { key: 'aliyun', - title: '阿里云配置', + title: '阿里云', icon: 'fa fas fa-cloud', fields: [ - { key: 'sms_ali_api_type', label: '接口类型', type: 'radio', default: 'sms', options: [{ label: '短信服务', value: 'sms' }, { label: '号码认证', value: 'verify' }], dependsOn: { key: 'sms_provider', value: 'aliyun' } }, - { key: 'sms_region_id', label: 'RegionId', type: 'input', default: 'cn-hangzhou', dependsOn: { key: 'sms_provider', value: 'aliyun' } }, + { + key: 'sms_ali_api_type', + label: '产品', + type: 'radio', + default: 'sms', + options: [ + { label: '短信服务', value: 'sms' }, + { label: '号码认证:短信认证服务', value: 'verify' }, + ], + dependsOn: { key: 'sms_sdk', value: 'ali' }, + }, + { key: 'sms_ali_keyid', label: 'AccessKey Id', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'ali' } }, + { key: 'sms_ali_keysecret', label: 'AccessKey Secret', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'ali' } }, + { key: 'sms_ali_sign_name', label: '签名', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'ali' } }, + { key: 'sms_ali_template_code', label: '模板 CODE', type: 'input', default: '', dependsOn: { all: [{ key: 'sms_sdk', value: 'ali' }, { key: 'sms_ali_api_type', not: true, value: 'verify' }] }, help: '短信服务需要填写。号码认证产品固定使用平台模板。' }, + { key: 'sms_region_id', label: 'RegionId', type: 'input', default: 'cn-hangzhou', dependsOn: { key: 'sms_sdk', value: 'ali' } }, ], }, { key: 'tencent', - title: '腾讯云配置', + title: '腾讯云', icon: 'fa fas fa-cloud', fields: [ - { key: 'sms_tencent_app_id', label: 'SdkAppId', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' }, help: '腾讯云短信应用 ID' }, - { key: 'sms_tencent_app_key', label: 'AppKey', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } }, - { key: 'sms_tencent_secret_id', label: 'SecretId', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } }, - { key: 'sms_tencent_secret_key', label: 'SecretKey', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } }, - { key: 'sms_tencent_region', label: '地域', type: 'input', default: 'ap-guangzhou', dependsOn: { key: 'sms_provider', value: 'tencent' } }, + { key: 'sms_tencent_app_id', label: 'SDK AppID', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' } }, + { key: 'sms_tencent_app_key', label: 'App Key', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' } }, + { key: 'sms_tencent_sign_name', label: '签名', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' } }, + { key: 'sms_tencent_template_id', label: '模板 ID', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' }, help: '模板包含验证码和有效分钟数两个参数。' }, ], }, { - key: 'huawei', - title: '华为云配置', + key: 'smsbao', + title: '短信宝', icon: 'fa fas fa-cloud', fields: [ - { key: 'sms_huawei_sender', label: '通道号 (sender)', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' } }, - { key: 'sms_huawei_signature', label: '短信签名', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' }, help: '华为云短信签名内容' }, - { key: 'sms_huawei_endpoint', label: 'API Endpoint', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' }, help: '留空使用默认 endpoint' }, - ], - }, - { - key: 'custom', - title: '自定义接口配置', - icon: 'fa fas fa-plug', - fields: [ - { key: 'sms_custom_url', label: '请求 URL', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '支持 {phone} {code} {time} 变量' }, - { key: 'sms_custom_method', label: '请求方法', type: 'select', default: 'POST', options: [{ label: 'POST', value: 'POST' }, { label: 'GET', value: 'GET' }, { label: 'PUT', value: 'PUT' }], dependsOn: { key: 'sms_provider', value: 'custom' } }, - { key: 'sms_custom_auth_type', label: '鉴权方式', type: 'select', default: 'header', options: [{ label: 'Header', value: 'header' }], dependsOn: { key: 'sms_provider', value: 'custom' } }, - { key: 'sms_custom_auth_key', label: '鉴权 Header 名', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' } }, - { key: 'sms_custom_auth_value', label: '鉴权 Header 值', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '支持 {phone} {code} {time} 变量' }, - { key: 'sms_custom_headers', label: '自定义 Headers', type: 'textarea', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '每行一个,格式:Key: Value' }, - { key: 'sms_custom_body', label: '请求 Body', type: 'textarea', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: 'JSON 格式,支持 {phone} {code} {time} 变量' }, - { key: 'sms_custom_success_rule', label: '成功判定规则', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '格式:status:200 或 json:code=0 或 contains:success' }, - ], - }, - { - key: 'template', - title: '短信模板', - icon: 'fa fas fa-comment', - fields: [ - { key: 'sms_sign_name', label: '短信签名', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '在短信服务商申请的短信签名(所有供应商共用此字段)' }, - { key: 'sms_template_code', label: '验证码模板 ID', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '所有供应商共用此模板 ID' }, - { key: 'sms_code_length', label: '验证码长度', type: 'number', default: 6, min: 4, max: 8, dependsOn: { key: 'sms_enabled', value: true } }, - { key: 'sms_code_expire', label: '验证码有效期 (秒)', type: 'number', default: 300, min: 60, max: 600, dependsOn: { key: 'sms_enabled', value: true } }, - ], - }, - { - key: 'rate_limit', - title: '限流策略', - icon: 'fa fas fa-shield-alt', - fields: [ - { key: 'sms_rate_limit', label: '发送间隔 (秒)', type: 'number', default: 60, min: 10, max: 300, dependsOn: { key: 'sms_enabled', value: true }, help: '同一手机号两次发送的最小间隔' }, - { key: 'sms_daily_limit', label: '每日发送上限', type: 'number', default: 10, min: 1, max: 100, dependsOn: { key: 'sms_enabled', value: true }, help: '同一手机号每天最多发送条数' }, - ], - }, - { - key: 'triggers', - title: '通知场景', - icon: 'fa fas fa-bell', - fields: [ - { key: 'sms_notify_bind', label: '绑定手机时验证', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } }, - { key: 'sms_notify_login', label: '短信登录', type: 'switch', default: false, dependsOn: { key: 'sms_enabled', value: true } }, - { key: 'sms_notify_register', label: '注册验证', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } }, - { key: 'sms_notify_reset_password', label: '找回密码', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } }, + { key: 'sms_smsbao_username', label: '用户名', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' } }, + { key: 'sms_smsbao_password', label: '密码', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' } }, + { key: 'sms_smsbao_api_key', label: 'ApiKey', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' }, help: '可选;填写后以 ApiKey 认证,可与密码任选其一。' }, + { key: 'sms_smsbao_template', label: '模板内容', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' }, help: '必须包含 {code},有效时间变量为 {time}。' }, ], }, ], -} \ No newline at end of file +} diff --git a/assets/js/routes/user-center.js b/assets/js/routes/user-center.js index dcebd010..ec3254e1 100644 --- a/assets/js/routes/user-center.js +++ b/assets/js/routes/user-center.js @@ -1112,10 +1112,16 @@ if (!form) return; if (form.dataset.aksrtSettingsInitialized === 'true') return; form.dataset.aksrtSettingsInitialized = 'true'; + function isValidSmsPhone(phone) { + if (!window.aksrtData || aksrtData.smsNumberLimitCh !== false) { + return /^1[3-9]\d{9}$/.test(phone); + } + return /^(?:\d{5,}|\+\d{6,}|\+?\d{2,}[-\s]\d{2,}[0-9\-\s]{0,15})$/.test(phone); + } var sendButton = form.querySelector('[data-aksrt-sms-trigger]'); if (sendButton) sendButton.addEventListener('click', function () { var phone = String((form.querySelector('[name="phone"]') || {}).value || '').trim(); - if (!/^1[3-9]\d{9}$/.test(phone)) { notyf('请输入正确的手机号', 'warning'); return; } + if (!isValidSmsPhone(phone)) { notyf('请输入正确的手机号', 'warning'); return; } var toastId = 'aksrt-settings-phone-code-toast'; var restRoot = (window.aksrtData && aksrtData.apiUrl) || '/wp-json/aksrt/v1'; var nonce = (window.aksrtData && aksrtData.restNonce) || ''; @@ -1146,11 +1152,11 @@ var codeEl = form.querySelector('[name="code"]'); var phone = phoneEl ? String(phoneEl.value || '').trim() : ''; var code = codeEl ? String(codeEl.value || '').trim() : ''; - if (!/^1[3-9]\d{9}$/.test(phone)) { + if (!isValidSmsPhone(phone)) { notyf('请输入正确的手机号', 'warning'); return; } - if (!code) { + if (!/^\d{6}$/.test(code)) { notyf('请输入短信验证码', 'warning'); return; } diff --git a/inc/helpers/user-center-renderers/profile-account-modals.php b/inc/helpers/user-center-renderers/profile-account-modals.php index d10fab3c..be470e39 100644 --- a/inc/helpers/user-center-renderers/profile-account-modals.php +++ b/inc/helpers/user-center-renderers/profile-account-modals.php @@ -110,12 +110,12 @@ extract($context, EXTR_SKIP); // phpcs:ignore WordPress.PHP.DontExtract.extract_
diff --git a/src/Admin/Schema/SchemaUsers.php b/src/Admin/Schema/SchemaUsers.php index 60d33ac6..a82ea3f9 100644 --- a/src/Admin/Schema/SchemaUsers.php +++ b/src/Admin/Schema/SchemaUsers.php @@ -389,71 +389,42 @@ class SchemaUsers { 'sms' => [ 'key' => 'sms', 'title' => '短信接口', - 'version' => '1.0.0', + 'version' => '2.0.0', 'option_key' => 'aksrt_settings', 'groups' => [ - ['key' => 'provider', 'title' => '供应商配置', 'fields' => [ - ['key' => 'sms_enabled', 'label' => '启用短信', 'type' => 'switch', 'default' => false], - ['key' => 'sms_provider', 'label' => '短信服务商', 'type' => 'select', 'default' => 'aliyun', 'dependsOn' => ['key' => 'sms_enabled', 'value' => true], 'options' => [ - ['label' => '阿里云短信', 'value' => 'aliyun'], + ['key' => 'provider', 'title' => '短信接口', 'fields' => [ + ['key' => 'sms_sdk', 'label' => '设置短信接口', 'type' => 'select', 'default' => 'null', 'options' => [ + ['label' => '不使用', 'value' => 'null'], + ['label' => '阿里云短信', 'value' => 'ali'], ['label' => '腾讯云短信', 'value' => 'tencent'], - ['label' => '华为云短信', 'value' => 'huawei'], - ['label' => '自定义接口', 'value' => 'custom'], + ['label' => '短信宝', 'value' => 'smsbao'], ]], - ['key' => 'sms_number_limit_ch', 'label' => '仅发国内', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'sms_enabled', 'value' => true], 'desc' => '开启后仅支持发送国内短信,关闭后可发送港澳台及国际短信'], - ['key' => 'sms_access_key', 'label' => 'AccessKey / SecretId / AppKey', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_enabled', 'value' => true]], - ['key' => 'sms_access_secret', 'label' => 'AccessSecret / SecretKey / AppSecret', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_enabled', 'value' => true]], + ['key' => 'sms_number_limit_ch', 'label' => '限制国内手机号', 'type' => 'switch', 'default' => true, 'desc' => '开启后只允许发送到中国大陆手机号'], + ['key' => 'sms_test_phone', 'label' => '测试手机号', 'type' => 'input', 'default' => '', 'ui_only' => true, 'help' => '保存接口配置后,发送固定验证码 888888 测试短信'], + ['key' => 'sms_test_send', 'label' => '发送测试短信', 'type' => 'button', 'buttonType' => 'primary', 'buttonText' => '发送测试短信', 'ui_only' => true], ]], - ['key' => 'aliyun', 'title' => '阿里云配置', 'fields' => [ - ['key' => 'sms_ali_api_type', 'label' => '接口类型', 'type' => 'radio', 'default' => 'sms', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'aliyun'], 'options' => [ + ['key' => 'aliyun', 'title' => '阿里云', 'fields' => [ + ['key' => 'sms_ali_api_type', 'label' => '产品', 'type' => 'radio', 'default' => 'sms', 'options' => [ ['label' => '短信服务', 'value' => 'sms'], - ['label' => '号码认证', 'value' => 'verify'], - ]], - ['key' => 'sms_region_id', 'label' => 'RegionId', 'type' => 'input', 'default' => 'cn-hangzhou', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'aliyun']], + ['label' => '号码认证:短信认证服务', 'value' => 'verify'], + ], 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'ali']], + ['key' => 'sms_ali_keyid', 'label' => 'AccessKey Id', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'ali']], + ['key' => 'sms_ali_keysecret', 'label' => 'AccessKey Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => '', 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'ali']], + ['key' => 'sms_ali_sign_name', 'label' => '签名', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'ali']], + ['key' => 'sms_ali_template_code', 'label' => '模板 CODE', 'type' => 'input', 'default' => '', 'dependsOn' => ['all' => [['key' => 'sms_sdk', 'value' => 'ali'], ['key' => 'sms_ali_api_type', 'not' => true, 'value' => 'verify']]], 'help' => '短信服务需要填写。号码认证产品固定使用平台模板'], + ['key' => 'sms_region_id', 'label' => 'RegionId', 'type' => 'input', 'default' => 'cn-hangzhou', 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'ali']], ]], - ['key' => 'tencent', 'title' => '腾讯云配置', 'fields' => [ - ['key' => 'sms_tencent_app_id', 'label' => 'AppID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'tencent']], - ['key' => 'sms_tencent_app_key', 'label' => 'AppKey', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'tencent']], - ['key' => 'sms_tencent_secret_id', 'label' => 'SecretId', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'tencent']], - ['key' => 'sms_tencent_secret_key', 'label' => 'SecretKey', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'tencent']], - ['key' => 'sms_tencent_region', 'label' => '地域', 'type' => 'input', 'default' => 'ap-guangzhou', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'tencent']], + ['key' => 'tencent', 'title' => '腾讯云', 'fields' => [ + ['key' => 'sms_tencent_app_id', 'label' => 'SDK AppID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'tencent']], + ['key' => 'sms_tencent_app_key', 'label' => 'App Key', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => '', 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'tencent']], + ['key' => 'sms_tencent_sign_name', 'label' => '签名', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'tencent']], + ['key' => 'sms_tencent_template_id', 'label' => '模板 ID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'tencent'], 'help' => '模板包含验证码和有效分钟数两个参数'], ]], - ['key' => 'huawei', 'title' => '华为云配置', 'fields' => [ - ['key' => 'sms_huawei_sender', 'label' => '通道号 (sender)', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'huawei']], - ['key' => 'sms_huawei_signature', 'label' => '短信签名', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'huawei']], - ['key' => 'sms_huawei_endpoint', 'label' => 'API Endpoint', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'huawei']], - ]], - ['key' => 'custom', 'title' => '自定义接口配置', 'fields' => [ - ['key' => 'sms_custom_url', 'label' => '请求 URL', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'custom']], - ['key' => 'sms_custom_method', 'label' => '请求方法', 'type' => 'select', 'default' => 'POST', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'custom'], 'options' => [ - ['label' => 'POST', 'value' => 'POST'], - ['label' => 'GET', 'value' => 'GET'], - ['label' => 'PUT', 'value' => 'PUT'], - ]], - ['key' => 'sms_custom_auth_type', 'label' => '鉴权方式', 'type' => 'select', 'default' => 'header', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'custom'], 'options' => [ - ['label' => 'Header', 'value' => 'header'], - ]], - ['key' => 'sms_custom_auth_key', 'label' => '鉴权 Header 名', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'custom']], - ['key' => 'sms_custom_auth_value', 'label' => '鉴权 Header 值', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'custom']], - ['key' => 'sms_custom_headers', 'label' => '自定义 Headers', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'custom']], - ['key' => 'sms_custom_body', 'label' => '请求 Body', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'custom']], - ['key' => 'sms_custom_success_rule', 'label' => '成功判定规则', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_provider', 'value' => 'custom']], - ]], - ['key' => 'template', 'title' => '短信模板', 'fields' => [ - ['key' => 'sms_sign_name', 'label' => '短信签名', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_enabled', 'value' => true]], - ['key' => 'sms_template_code', 'label' => '验证码模板ID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_enabled', 'value' => true]], - ['key' => 'sms_code_length', 'label' => '验证码长度', 'type' => 'number', 'default' => 6, 'min' => 4, 'max' => 8, 'dependsOn' => ['key' => 'sms_enabled', 'value' => true]], - ['key' => 'sms_code_expire', 'label' => '验证码有效期(秒)', 'type' => 'number', 'default' => 300, 'min' => 60, 'max' => 600, 'dependsOn' => ['key' => 'sms_enabled', 'value' => true]], - ]], - ['key' => 'rate_limit', 'title' => '限流策略', 'fields' => [ - ['key' => 'sms_rate_limit', 'label' => '发送间隔(秒)', 'type' => 'number', 'default' => 60, 'min' => 10, 'max' => 300, 'dependsOn' => ['key' => 'sms_enabled', 'value' => true]], - ['key' => 'sms_daily_limit', 'label' => '每日发送上限', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 100, 'dependsOn' => ['key' => 'sms_enabled', 'value' => true]], - ]], - ['key' => 'triggers', 'title' => '通知场景', 'fields' => [ - ['key' => 'sms_notify_bind', 'label' => '绑定手机验证', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'sms_enabled', 'value' => true]], - ['key' => 'sms_notify_login', 'label' => '短信登录', 'type' => 'switch', 'default' => false, 'dependsOn' => ['key' => 'sms_enabled', 'value' => true]], - ['key' => 'sms_notify_register', 'label' => '注册验证', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'sms_enabled', 'value' => true]], - ['key' => 'sms_notify_reset_password', 'label' => '找回密码', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'sms_enabled', 'value' => true]], + ['key' => 'smsbao', 'title' => '短信宝', 'fields' => [ + ['key' => 'sms_smsbao_username', 'label' => '用户名', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'smsbao']], + ['key' => 'sms_smsbao_password', 'label' => '密码', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => '', 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'smsbao']], + ['key' => 'sms_smsbao_api_key', 'label' => 'ApiKey', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => '', 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'smsbao'], 'help' => '可选;填写后以 ApiKey 认证,可与密码任选其一'], + ['key' => 'sms_smsbao_template', 'label' => '模板内容', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'sms_sdk', 'value' => 'smsbao'], 'help' => '必须包含 {code},有效时间变量为 {time}'], ]], ], ], diff --git a/src/Admin/UserController.php b/src/Admin/UserController.php index ba59a442..ac1cfcb9 100644 --- a/src/Admin/UserController.php +++ b/src/Admin/UserController.php @@ -232,19 +232,11 @@ class UserController extends ControllerBase { 'permission_callback' => '__return_true', ]); - // SMS provider status - register_rest_route(self::API_NAMESPACE, '/sms/status', [ - 'methods' => 'GET', - 'callback' => [$this, 'get_sms_status'], - 'permission_callback' => [$this, 'check_permission'], - ]); - - // SMS test send (admin only) register_rest_route(self::API_NAMESPACE, '/sms/test', [ - 'methods' => 'POST', - 'callback' => [$this, 'test_sms_send'], - 'permission_callback' => [$this, 'check_permission'], - ]); + 'methods' => 'POST', + 'callback' => [$this, 'test_sms_send'], + 'permission_callback' => [$this, 'check_permission'], + ]); // WeChat notification status register_rest_route(self::API_NAMESPACE, '/wechat/status', [ diff --git a/src/Admin/UserControllerAuthPhoneOperations.php b/src/Admin/UserControllerAuthPhoneOperations.php index a750218a..451dfe49 100644 --- a/src/Admin/UserControllerAuthPhoneOperations.php +++ b/src/Admin/UserControllerAuthPhoneOperations.php @@ -72,31 +72,33 @@ trait UserControllerAuthPhoneOperations { return 'aksrt_auth_code_' . md5($scene . '|' . strtolower(trim($target))); } - private function auth_store_email_code(string $scene, string $target, string $code): void { + private function auth_store_code(string $scene, string $target, string $code, int $ttl = 10 * MINUTE_IN_SECONDS): void { set_transient($this->auth_code_key($scene, $target), [ 'hash' => wp_hash_password($code), 'attempts' => 0, - ], 10 * MINUTE_IN_SECONDS); + 'expires' => time() + $ttl, + ], $ttl); } - private function auth_consume_code(string $scene, string $target, string $code, string $channel): bool { - if ($channel === 'phone') { - if (!class_exists('\AKSRT\Sms\SMS')) { - return false; - } - $result = \AKSRT\Sms\SMS::get_instance()->verify_code($target, $code); - return !empty($result['success']); - } - + private function auth_consume_code(string $scene, string $target, string $code): bool { $key = $this->auth_code_key($scene, $target); $stored = get_transient($key); if (!is_array($stored) || empty($stored['hash'])) { return false; } + $remaining = (int) ($stored['expires'] ?? 0) - time(); + if ($remaining <= 0) { + delete_transient($key); + return false; + } $attempts = (int) ($stored['attempts'] ?? 0) + 1; - if ($attempts > 6 || !wp_check_password($code, (string) $stored['hash'])) { + if ($attempts > 6) { + delete_transient($key); + return false; + } + if (!wp_check_password($code, (string) $stored['hash'])) { $stored['attempts'] = $attempts; - set_transient($key, $stored, 10 * MINUTE_IN_SECONDS); + set_transient($key, $stored, $remaining); return false; } delete_transient($key); @@ -104,7 +106,7 @@ trait UserControllerAuthPhoneOperations { } private function auth_channel(string $target, string $requested = ''): string { - if ($requested === 'phone' || preg_match('/^1[3-9]\d{9}$/', $target)) { + if ($requested === 'phone' || (class_exists('\\AKSRT\\Sms\\SMS') && \AKSRT\Sms\SMS::is_phonenumber($target))) { return 'phone'; } return 'email'; @@ -162,7 +164,7 @@ trait UserControllerAuthPhoneOperations { $enabled = $scene === 'login' ? \AKSRT\Settings\Settings::passwordless_login_enabled() : ($scene === 'register' ? \AKSRT\Settings\Settings::signup_verification_enabled() : true); if (!$enabled || ($type !== 'email_phone' && $type !== $channel)) return $this->error(__('当前验证方式不可用', 'aksrt'), 403); } - if (($channel === 'email' && !is_email($target)) || ($channel === 'phone' && !preg_match('/^1[3-9]\d{9}$/', $target))) { + if (($channel === 'email' && !is_email($target)) || ($channel === 'phone' && !\AKSRT\Sms\SMS::is_phonenumber($target))) { return $this->error(__('请输入有效的邮箱或手机号', 'aksrt')); } if ($this->auth_rate_limited('code:' . $scene, $target, 5, HOUR_IN_SECONDS)) { @@ -183,10 +185,12 @@ trait UserControllerAuthPhoneOperations { if (!class_exists('\AKSRT\Sms\SMS')) { return $this->error(__('短信服务不可用', 'aksrt'), 503); } - $result = \AKSRT\Sms\SMS::get_instance()->send_code($target); + $code = (string) random_int(100000, 999999); + $result = \AKSRT\Sms\SMS::get_instance()->send_code($target, $code); if (empty($result['success'])) { return $this->error((string) ($result['message'] ?? __('验证码发送失败', 'aksrt'))); } + $this->auth_store_code($scene, $target, $code, 30 * MINUTE_IN_SECONDS); return $this->success(['message' => $generic, 'countdown' => (int) ($result['remaining_time'] ?? 60)]); } @@ -215,7 +219,7 @@ trait UserControllerAuthPhoneOperations { } return $this->error(__('验证码发送失败,请稍后再试', 'aksrt'), 500); } - $this->auth_store_email_code($scene, $target, $code); + $this->auth_store_code($scene, $target, $code); return $this->success(['message' => $generic, 'countdown' => 60]); } @@ -239,7 +243,7 @@ trait UserControllerAuthPhoneOperations { ) { return $this->error(__('当前登录方式已关闭', 'aksrt'), 403); } - if ($target === '' || (preg_match('/^1[3-9]\d{9}$/', $target) && empty($payload['code']) && class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::signin_phone_enabled())) { + if ($target === '' || (\AKSRT\Sms\SMS::is_phonenumber($target) && empty($payload['code']) && class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::signin_phone_enabled())) { return $this->error(__('账号或密码错误', 'aksrt'), 401); } if ($this->auth_rate_limited('login', $target)) { @@ -261,7 +265,7 @@ trait UserControllerAuthPhoneOperations { return $this->error(__('当前验证方式不可用', 'aksrt'), 403); } $user = $this->auth_find_user($target, $channel); - if (!$user || !$this->auth_consume_code('login', $target, sanitize_text_field((string) $payload['code']), $channel)) { + if (!$user || !$this->auth_consume_code('login', $target, sanitize_text_field((string) $payload['code']))) { return $this->error(__('账号或验证码错误', 'aksrt'), 401); } if ($oauth_data) { @@ -348,7 +352,7 @@ trait UserControllerAuthPhoneOperations { if (!$this->auth_channel_allowed($configured, $channel)) { return $this->error(__('当前注册方式不可用', 'aksrt'), 403); } - if (($channel === 'email' && !is_email($target)) || ($channel === 'phone' && !preg_match('/^1[3-9]\d{9}$/', $target))) { + if (($channel === 'email' && !is_email($target)) || ($channel === 'phone' && !\AKSRT\Sms\SMS::is_phonenumber($target))) { return $this->error(__('请输入有效的邮箱或手机号', 'aksrt')); } $captcha_error = $this->auth_require_captcha('register', $payload); @@ -357,7 +361,7 @@ trait UserControllerAuthPhoneOperations { } $verification_completed = false; if ($signup_verification) { - if (!$this->auth_consume_code('register', $target, sanitize_text_field((string) ($payload['code'] ?? '')), $channel)) { + if (!$this->auth_consume_code('register', $target, sanitize_text_field((string) ($payload['code'] ?? '')))) { return $this->error(__('验证码错误或已过期', 'aksrt')); } $verification_completed = true; @@ -426,7 +430,7 @@ trait UserControllerAuthPhoneOperations { return $this->error(__('当前验证方式不可用', 'aksrt'), 403); } } - if (($channel === 'email' && !is_email($target)) || ($channel === 'phone' && !preg_match('/^1[3-9]\d{9}$/', $target))) { + if (($channel === 'email' && !is_email($target)) || ($channel === 'phone' && !\AKSRT\Sms\SMS::is_phonenumber($target))) { return $this->error(__('请输入有效的邮箱或手机号', 'aksrt')); } if ($this->auth_rate_limited('reset-password', $target, 8, HOUR_IN_SECONDS)) { @@ -446,7 +450,7 @@ trait UserControllerAuthPhoneOperations { if ($password_check !== true) { return $this->error((string) $password_check); } - if (!$user || !$this->auth_consume_code('resetpassword', $target, sanitize_text_field((string) ($payload['code'] ?? '')), $channel)) { + if (!$user || !$this->auth_consume_code('resetpassword', $target, sanitize_text_field((string) ($payload['code'] ?? '')))) { return $this->error(__('账号或验证码错误', 'aksrt')); } wp_set_password($password, (int) $user->ID); @@ -459,16 +463,19 @@ trait UserControllerAuthPhoneOperations { return $this->error(__('安全验证失败', 'aksrt'), 403); } $phone = sanitize_text_field((string) ($payload['phone'] ?? '')); - if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { + if (!\AKSRT\Sms\SMS::is_phonenumber($phone)) { return $this->error(__('手机号格式不正确', 'aksrt')); } $captcha_error = $this->auth_require_captcha('bind_phone', $payload); if ($captcha_error) return $captcha_error; if (!class_exists('\AKSRT\Sms\SMS')) return $this->error(__('短信服务不可用', 'aksrt'), 503); - $result = \AKSRT\Sms\SMS::get_instance()->send_code($phone); - return !empty($result['success']) - ? $this->success(['message' => __('验证码已发送', 'aksrt'), 'countdown' => (int) ($result['remaining_time'] ?? 60)]) - : $this->error((string) ($result['message'] ?? __('发送失败', 'aksrt'))); + $code = (string) random_int(100000, 999999); + $result = \AKSRT\Sms\SMS::get_instance()->send_code($phone, $code); + if (empty($result['success'])) { + return $this->error((string) ($result['message'] ?? __('发送失败', 'aksrt'))); + } + $this->auth_store_code('bind_phone', $phone, $code, 30 * MINUTE_IN_SECONDS); + return $this->success(['message' => __('验证码短信已发送', 'aksrt'), 'countdown' => (int) ($result['remaining_time'] ?? 60)]); } public function user_bind_phone(\WP_REST_Request $request): \WP_REST_Response { @@ -476,8 +483,25 @@ trait UserControllerAuthPhoneOperations { if (!$this->auth_verify_rest_nonce($request, $payload)) return $this->error(__('安全验证失败', 'aksrt'), 403); $captcha_error = $this->auth_require_captcha('bind_phone', $payload); if ($captcha_error) return $captcha_error; + $phone = sanitize_text_field((string) ($payload['phone'] ?? '')); + $code = sanitize_text_field((string) ($payload['code'] ?? '')); + if (!$this->auth_consume_code('bind_phone', $phone, $code)) { + return $this->error(__('验证码错误或已过期', 'aksrt')); + } if (!class_exists('\AKSRT\Auth\Service')) return $this->error(__('认证服务不可用', 'aksrt'), 500); - $result = \AKSRT\Auth\Service::bind_phone($payload); + $result = \AKSRT\Auth\Service::bind_phone($payload, true); return !empty($result['success']) ? $this->success(['message' => $result['message'] ?? __('绑定成功', 'aksrt')]) : $this->error((string) ($result['error'] ?? __('绑定失败', 'aksrt'))); } + + public function test_sms_send(\WP_REST_Request $request): \WP_REST_Response { + $params = $this->get_json_params($request); + $phone = sanitize_text_field((string) ($params['phone'] ?? '')); + if (!class_exists('\\AKSRT\\Sms\\SMS')) { + return $this->error(__('短信服务不可用', 'aksrt'), 503); + } + $result = \AKSRT\Sms\SMS::get_instance()->test_send($phone); + return !empty($result['success']) + ? $this->success(['message' => __('测试短信已发送', 'aksrt')]) + : $this->error((string) ($result['message'] ?? __('测试短信发送失败', 'aksrt'))); + } } diff --git a/src/Auth/Service.php b/src/Auth/Service.php index a046ab46..07ad8973 100644 --- a/src/Auth/Service.php +++ b/src/Auth/Service.php @@ -45,7 +45,7 @@ final class Service { public static function resolve_email_to_username(string $login): string { $login = trim($login); if ( - preg_match('/^1[3-9]\d{9}$/', $login) + \AKSRT\Sms\SMS::is_phonenumber($login) && class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::signin_phone_enabled() ) { diff --git a/src/Auth/ServicePart1b.php b/src/Auth/ServicePart1b.php index deb2c60c..15735eb3 100644 --- a/src/Auth/ServicePart1b.php +++ b/src/Auth/ServicePart1b.php @@ -21,7 +21,7 @@ trait ServicePart1b { $confirm = (string) ($post['password_confirm'] ?? ''); $username = sanitize_user(wp_unslash((string) ($post['user_login'] ?? '')), true); - if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { + if (!\AKSRT\Sms\SMS::is_phonenumber($phone)) { return ['success' => false, 'error' => __('手机号格式不正确', 'aksrt')]; } if (!$verification_completed) { @@ -159,26 +159,18 @@ trait ServicePart1b { * @param array $post (phone, code) * @return array */ - public static function bind_phone(array $post): array { + public static function bind_phone(array $post, bool $verification_completed = false): array { if (!is_user_logged_in()) { return ['success' => false, 'error' => __('请先登录', 'aksrt')]; } $phone = sanitize_text_field((string) ($post['phone'] ?? '')); - $code = sanitize_text_field((string) ($post['code'] ?? '')); - - if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { + if (!\AKSRT\Sms\SMS::is_phonenumber($phone)) { return ['success' => false, 'error' => __('手机号格式不正确', 'aksrt')]; } - // 验证 SMS 验证码 - if (class_exists('\AKSRT\Sms\SMS')) { - $verify_result = \AKSRT\Sms\SMS::get_instance()->verify_code($phone, $code); - if (empty($verify_result['success'])) { - return ['success' => false, 'error' => $verify_result['message'] ?? __('验证码不正确', 'aksrt')]; - } - } else { - return ['success' => false, 'error' => __('短信服务不可用', 'aksrt')]; + if (!$verification_completed) { + return ['success' => false, 'error' => __('验证码错误或已过期', 'aksrt')]; } // 检查手机号是否已被其他用户绑定 diff --git a/src/Core/ScriptsPart1c.php b/src/Core/ScriptsPart1c.php index 36eae0db..df87b467 100644 --- a/src/Core/ScriptsPart1c.php +++ b/src/Core/ScriptsPart1c.php @@ -452,6 +452,7 @@ trait ScriptsPart1c { 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('aksrt_public_nonce'), 'restNonce' => wp_create_nonce('wp_rest'), + 'smsNumberLimitCh' => class_exists('\\AKSRT\\Settings\\Settings') ? !empty(\AKSRT\Settings\Settings::get_sms_settings()['sms_number_limit_ch']) : true, 'captchaSignin' => function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('signin'), // 已签到详情弹窗(签到成功后就地替换入口按钮用)。 'signinDetailsUrl' => esc_url_raw(rest_url('aksrt/v1/signin/details')), diff --git a/src/Settings/ExtensionRuntime.php b/src/Settings/ExtensionRuntime.php index 2a403e0d..5c0f8a47 100644 --- a/src/Settings/ExtensionRuntime.php +++ b/src/Settings/ExtensionRuntime.php @@ -154,20 +154,14 @@ class ExtensionRuntime { ]; } - /** - * 获取短信接口设置 - * - * 注意:所有供应商共用 sms_access_key / sms_access_secret(通用凭证字段)。 - * 各供应商特有的额外字段使用各自的命名空间(sms_tencent_*, sms_huawei_*, sms_custom_*)。 - * sms_sign_name / sms_template_code 为所有供应商共用。 - */ + /** 获取短信接口设置。 */ public static function get_sms_settings(): array { $settings = Settings::get_all(); - $provider = sanitize_key((string) ($settings['sms_provider'] ?? 'aliyun')); - $allowed_providers = ['aliyun', 'tencent', 'huawei', 'custom']; - if (!in_array($provider, $allowed_providers, true)) { - $provider = 'aliyun'; + $sdk = sanitize_key((string) ($settings['sms_sdk'] ?? 'null')); + $allowed_sdks = ['null', 'ali', 'tencent', 'smsbao']; + if (!in_array($sdk, $allowed_sdks, true)) { + $sdk = 'null'; } $ali_api_type = sanitize_key((string) ($settings['sms_ali_api_type'] ?? 'sms')); @@ -175,53 +169,26 @@ class ExtensionRuntime { $ali_api_type = 'sms'; } - $code_length = max(4, min(8, (int) ($settings['sms_code_length'] ?? 6))); - $code_expire = max(60, min(600, (int) ($settings['sms_code_expire'] ?? 300))); - $rate_limit = max(10, (int) ($settings['sms_rate_limit'] ?? 60)); - $daily_limit = max(1, (int) ($settings['sms_daily_limit'] ?? 10)); - return [ - 'sms_enabled' => !empty($settings['sms_enabled']), - 'sms_provider' => $provider, + 'sms_sdk' => $sdk, 'sms_number_limit_ch' => !isset($settings['sms_number_limit_ch']) || !empty($settings['sms_number_limit_ch']), - 'sms_access_key' => (string) ($settings['sms_access_key'] ?? ''), - 'sms_access_secret' => (string) ($settings['sms_access_secret'] ?? ''), - 'sms_sign_name' => (string) ($settings['sms_sign_name'] ?? ''), - 'sms_template_code' => (string) ($settings['sms_template_code'] ?? ''), - 'sms_code_length' => $code_length, - 'sms_code_expire' => $code_expire, - 'sms_rate_limit' => $rate_limit, - 'sms_daily_limit' => $daily_limit, - 'sms_notify_bind' => !isset($settings['sms_notify_bind']) || !empty($settings['sms_notify_bind']), - 'sms_notify_login' => !empty($settings['sms_notify_login']), - 'sms_notify_register' => !isset($settings['sms_notify_register']) || !empty($settings['sms_notify_register']), - 'sms_notify_reset_password' => !isset($settings['sms_notify_reset_password']) || !empty($settings['sms_notify_reset_password']), - // 阿里云特有 + // 阿里云 'sms_ali_api_type' => $ali_api_type, + 'sms_ali_keyid' => (string) ($settings['sms_ali_keyid'] ?? ''), + 'sms_ali_keysecret' => (string) ($settings['sms_ali_keysecret'] ?? ''), + 'sms_ali_sign_name' => (string) ($settings['sms_ali_sign_name'] ?? ''), + 'sms_ali_template_code' => (string) ($settings['sms_ali_template_code'] ?? ''), 'sms_region_id' => (string) ($settings['sms_region_id'] ?? 'cn-hangzhou'), - 'sms_aliyun_verified' => !empty($settings['sms_aliyun_verified']), - // 腾讯云特有 + // 腾讯云 'sms_tencent_app_id' => (string) ($settings['sms_tencent_app_id'] ?? ''), 'sms_tencent_app_key' => (string) ($settings['sms_tencent_app_key'] ?? ''), - 'sms_tencent_secret_id' => (string) ($settings['sms_tencent_secret_id'] ?? ''), - 'sms_tencent_secret_key' => (string) ($settings['sms_tencent_secret_key'] ?? ''), - 'sms_tencent_region' => (string) ($settings['sms_tencent_region'] ?? 'ap-guangzhou'), - 'sms_tencent_verified' => !empty($settings['sms_tencent_verified']), - // 华为云特有 - 'sms_huawei_sender' => (string) ($settings['sms_huawei_sender'] ?? ''), - 'sms_huawei_signature' => (string) ($settings['sms_huawei_signature'] ?? ''), - 'sms_huawei_endpoint' => (string) ($settings['sms_huawei_endpoint'] ?? ''), - 'sms_huawei_verified' => !empty($settings['sms_huawei_verified']), - // 自定义接口特有 - 'sms_custom_url' => (string) ($settings['sms_custom_url'] ?? ''), - 'sms_custom_method' => (string) ($settings['sms_custom_method'] ?? 'POST'), - 'sms_custom_auth_type' => (string) ($settings['sms_custom_auth_type'] ?? 'header'), - 'sms_custom_auth_key' => (string) ($settings['sms_custom_auth_key'] ?? ''), - 'sms_custom_auth_value' => (string) ($settings['sms_custom_auth_value'] ?? ''), - 'sms_custom_headers' => (string) ($settings['sms_custom_headers'] ?? ''), - 'sms_custom_body' => (string) ($settings['sms_custom_body'] ?? ''), - 'sms_custom_success_rule' => (string) ($settings['sms_custom_success_rule'] ?? ''), - 'sms_custom_verified' => !empty($settings['sms_custom_verified']), + 'sms_tencent_sign_name' => (string) ($settings['sms_tencent_sign_name'] ?? ''), + 'sms_tencent_template_id' => (string) ($settings['sms_tencent_template_id'] ?? ''), + // 短信宝 + 'sms_smsbao_username' => (string) ($settings['sms_smsbao_username'] ?? ''), + 'sms_smsbao_password' => (string) ($settings['sms_smsbao_password'] ?? ''), + 'sms_smsbao_api_key' => (string) ($settings['sms_smsbao_api_key'] ?? ''), + 'sms_smsbao_template' => (string) ($settings['sms_smsbao_template'] ?? ''), ]; } } diff --git a/src/Sms/ProviderInterface.php b/src/Sms/ProviderInterface.php index 96e538f7..c821ea75 100644 --- a/src/Sms/ProviderInterface.php +++ b/src/Sms/ProviderInterface.php @@ -14,11 +14,6 @@ interface ProviderInterface { */ public function get_key(): string; - /** - * 供应商显示名称 - */ - public function get_label(): string; - /** * 校验配置完整性,返回缺失字段列表 * diff --git a/src/Sms/SMS.php b/src/Sms/SMS.php index d5da150e..839f520b 100644 --- a/src/Sms/SMS.php +++ b/src/Sms/SMS.php @@ -7,335 +7,145 @@ namespace AKSRT\Sms; defined('ABSPATH') || exit; +/** + * ZibLL-style SMS dispatcher and send-rate limiting. + */ class SMS { - private static $instance = null; + private static $instance = null; - public static function get_instance(): self { - if (null === self::$instance) self::$instance = new self(); - return self::$instance; - } + private const RATE_LIMIT = 60; + private const DAILY_LIMIT = 10; - public function init(): void { - add_action('wp_ajax_aksrt_sms_test', [$this, 'ajax_test_provider']); - add_action('wp_ajax_aksrt_sms_status', [$this, 'ajax_provider_status']); - } + public static function get_instance(): self { + if (null === self::$instance) { + self::$instance = new self(); + } + return self::$instance; + } - /** - * 获取短信供应商配置 - */ - public function get_provider_config(): array { - if (class_exists('\AKSRT\Settings\Settings')) { - return \AKSRT\Settings\Settings::get_sms_settings(); - } - return ['sms_enabled' => false, 'sms_provider' => '']; - } + public function get_provider_config(): array { + if (class_exists('\\AKSRT\\Settings\\Settings')) { + return \AKSRT\Settings\Settings::get_sms_settings(); + } + return ['sms_sdk' => 'null', 'sms_number_limit_ch' => true]; + } - /** - * 获取当前 provider adapter - */ - public function get_provider(): ?ProviderInterface { - $config = $this->get_provider_config(); - $key = sanitize_key((string) ($config['sms_provider'] ?? '')); - return SMSProviderFactory::get($key); - } + /** + * Phone-number validation follows the selected domestic/international mode. + */ + public static function is_phonenumber(string $phone, ?bool $domestic_only = null): bool { + if ($domestic_only === null) { + $config = self::get_instance()->get_provider_config(); + $domestic_only = !empty($config['sms_number_limit_ch']); + } - /** - * 发送短信验证码 - * - * @param string $phone 手机号 - * @return array{success: bool, message: string, remaining_time?: int} - */ - public function send_code(string $phone): array { - $config = $this->get_provider_config(); + if ($domestic_only) { + return (bool) preg_match('/^1[3-9]\d{9}$/', $phone); + } - if (empty($config['sms_enabled'])) { - return ['success' => false, 'message' => __('短信服务未启用', 'aksrt')]; - } + return (bool) preg_match('/^(?:\d{5,}|\+\d{6,}|\+?\d{2,}[-\s]\d{2,}[0-9\-\s]{0,15})$/', $phone); + } - $provider_key = sanitize_key((string) ($config['sms_provider'] ?? '')); - if ($provider_key === '') { - return ['success' => false, 'message' => __('未配置短信供应商', 'aksrt')]; - } + /** + * Send a six-digit verification code. The caller stores it after success. + * + * @return array{success: bool, message: string, remaining_time?: int} + */ + public function send_code(string $phone, string $code): array { + $config = $this->get_provider_config(); + $sdk = sanitize_key((string) ($config['sms_sdk'] ?? 'null')); + if ($sdk === '' || $sdk === 'null') { + return ['success' => false, 'message' => __('短信接口未启用', 'aksrt')]; + } + if (!self::is_phonenumber($phone, !empty($config['sms_number_limit_ch']))) { + return ['success' => false, 'message' => __('手机号码格式有误', 'aksrt')]; + } - $provider = SMSProviderFactory::get($provider_key); - if ($provider === null) { - return ['success' => false, 'message' => sprintf(__('短信供应商 "%s" 不可用', 'aksrt'), $provider_key)]; - } + $provider = SMSProviderFactory::get($sdk); + if ($provider === null) { + return ['success' => false, 'message' => __('暂无短信接口,请检查短信服务商设置', 'aksrt')]; + } - // 校验配置完整性 - $missing = $provider->validate_config($config); - if (!empty($missing)) { - return ['success' => false, 'message' => sprintf(__('短信配置不完整,缺少: %s', 'aksrt'), implode(', ', $missing))]; - } + $missing = $provider->validate_config($config); + if ($missing) { + return ['success' => false, 'message' => sprintf(__('短信接口缺少配置:%s', 'aksrt'), implode(', ', $missing))]; + } - // 频率限制:间隔限制(对齐 legacy 60 秒冷却 + 返回剩余时间) - $rate_limit = max(10, (int) ($config['sms_rate_limit'] ?? 60)); - $rate_key = 'aksrt_sms_rate_' . md5($phone); - $rate_set = get_transient($rate_key); - if ($rate_set !== false) { - // 计算剩余等待时间 - $rate_ts_key = 'aksrt_sms_rate_ts_' . md5($phone); - $sent_time = (int) get_transient($rate_ts_key); - $elapsed = $sent_time > 0 ? time() - $sent_time : $rate_limit; - $remaining = max(1, $rate_limit - $elapsed); - return [ - 'success' => false, - 'message' => sprintf(__('请 %d 秒后再试', 'aksrt'), $remaining), - 'remaining_time' => $remaining, - ]; - } + $phone_hash = md5($phone); + $rate_limit = self::RATE_LIMIT; + $rate_key = 'aksrt_sms_rate_' . $phone_hash; + if (get_transient($rate_key) !== false) { + $sent_time = (int) get_transient('aksrt_sms_rate_ts_' . $phone_hash); + $remaining = max(1, $rate_limit - (time() - $sent_time)); + return [ + 'success' => false, + 'message' => sprintf(__('请 %d 秒后再试', 'aksrt'), $remaining), + 'remaining_time' => $remaining, + ]; + } - // 每日限制 - $daily_limit = max(1, (int) ($config['sms_daily_limit'] ?? 10)); - $daily_key = 'aksrt_sms_daily_' . md5($phone) . '_' . date('Ymd'); - $daily_count = (int) get_transient($daily_key); - if ($daily_count >= $daily_limit) { - return ['success' => false, 'message' => __('今日发送次数已达上限', 'aksrt')]; - } + $daily_limit = self::DAILY_LIMIT; + $daily_key = 'aksrt_sms_daily_' . $phone_hash . '_' . wp_date('Ymd'); + $daily_count = (int) get_transient($daily_key); + if ($daily_count >= $daily_limit) { + return ['success' => false, 'message' => __('今日发送次数已达上限', 'aksrt')]; + } - $code_length = max(4, min(8, (int) ($config['sms_code_length'] ?? 6))); - $code = $this->generate_code($code_length); - $ttl = max(60, min(600, (int) ($config['sms_code_expire'] ?? 300))); + $result = $provider->send_code($phone, $code, $config); + if (empty($result['success'])) { + return ['success' => false, 'message' => (string) ($result['message'] ?? __('验证码发送失败,请稍后重试', 'aksrt'))]; + } - // 存储验证码(绑定手机号) - set_transient('aksrt_sms_code_' . md5($phone), $code, $ttl); - set_transient('aksrt_sms_phone_' . md5($phone), $phone, $ttl); - set_transient($rate_key, '1', $rate_limit); - set_transient('aksrt_sms_rate_ts_' . md5($phone), time(), $rate_limit); - set_transient($daily_key, $daily_count + 1, DAY_IN_SECONDS); + set_transient($rate_key, '1', $rate_limit); + set_transient('aksrt_sms_rate_ts_' . $phone_hash, time(), $rate_limit); + set_transient($daily_key, $daily_count + 1, DAY_IN_SECONDS); - // 委托 provider 发送 - $result = $provider->send_code($phone, $code, $config); + return [ + 'success' => true, + 'message' => __('验证码短信已发送', 'aksrt'), + 'remaining_time' => $rate_limit, + ]; + } + public function get_provider_status(): array { + $config = $this->get_provider_config(); + $key = sanitize_key((string) ($config['sms_sdk'] ?? 'null')); + $enabled = $key !== '' && $key !== 'null'; + $provider = SMSProviderFactory::get($key); + $status = [ + 'enabled' => $enabled, + 'provider' => $enabled ? $key : 'none', + 'configured' => false, + 'available' => false, + 'verified' => false, + 'transport' => '', + 'missing_fields' => [], + ]; + if ($provider !== null) { + $status = array_merge($status, $provider->get_status($config)); + } + return $status; + } - if (!empty($result['success'])) { - return [ - 'success' => true, - 'message' => $result['message'] ?? __('验证码已发送', 'aksrt'), - 'remaining_time' => $rate_limit, - ]; - } + /** Send the reference theme's fixed 888888 test message without storing a code. */ + public function test_send(string $phone): array { + $config = $this->get_provider_config(); + $sdk = sanitize_key((string) ($config['sms_sdk'] ?? 'null')); + if ($sdk === '' || $sdk === 'null') { + return ['success' => false, 'message' => __('短信接口未启用', 'aksrt')]; + } + if (!self::is_phonenumber($phone, !empty($config['sms_number_limit_ch']))) { + return ['success' => false, 'message' => __('手机号码格式有误', 'aksrt')]; + } + $provider = SMSProviderFactory::get($sdk); + if ($provider === null) { + return ['success' => false, 'message' => __('暂无短信接口,请检查短信服务商设置', 'aksrt')]; + } + $missing = $provider->validate_config($config); + if ($missing) { + return ['success' => false, 'message' => sprintf(__('短信接口缺少配置:%s', 'aksrt'), implode(', ', $missing))]; + } + return $provider->send_code($phone, '888888', $config); + } - // 发送失败:删除已存储的验证码,避免无效等待 - delete_transient('aksrt_sms_code_' . md5($phone)); - delete_transient('aksrt_sms_phone_' . md5($phone)); - return ['success' => false, 'message' => $result['message'] ?? __('发送失败,请稍后重试', 'aksrt')]; - } - - /** - * 验证短信验证码(对齐 legacy:同时校验验证码和目标手机号) - * - * @param string $phone 手机号 - * @param string $code 验证码 - * @return array{success: bool, message: string} - */ - public function verify_code(string $phone, string $code): array { - // 错误频率限制检查 - $error_check = $this->check_error_frequency('sms_verify_' . md5($phone)); - if (!$error_check['allowed']) { - return [ - 'success' => false, - 'message' => sprintf(__('验证失败次数过多,请 %d 秒后再试', 'aksrt'), $error_check['remaining']), - 'remaining_time' => $error_check['remaining'], - ]; - } - - $code_key = 'aksrt_sms_code_' . md5($phone); - $phone_key = 'aksrt_sms_phone_' . md5($phone); - - $stored_code = get_transient($code_key); - $stored_phone = get_transient($phone_key); - - // 验证码不存在或已过期 - if ($stored_code === false) { - $this->record_error('sms_verify_' . md5($phone)); - return ['success' => false, 'message' => __('验证码已过期或不存在', 'aksrt')]; - } - - // 验证码不匹配 - if (!hash_equals((string) $stored_code, $code)) { - $this->record_error('sms_verify_' . md5($phone)); - return ['success' => false, 'message' => __('验证码不正确', 'aksrt')]; - } - - // 目标手机号绑定校验 - if ($stored_phone !== false && (string) $stored_phone !== $phone) { - $this->record_error('sms_verify_' . md5($phone)); - return ['success' => false, 'message' => __('手机号不匹配', 'aksrt')]; - } - - // 验证成功:清除验证码和错误计数 - delete_transient($code_key); - delete_transient($phone_key); - $this->reset_error_frequency('sms_verify_' . md5($phone)); - - return ['success' => true, 'message' => __('验证成功', 'aksrt')]; - } - - /** - * 生成验证码 - */ - private function generate_code(int $length = 6): string { - $max = (int) str_pad('9', $length, '9'); - return str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT); - } - - /* ------------------------------------------------------------------ - * 错误频率限制(对齐 legacy 的 error_frequency_limit) - * ----------------------------------------------------------------*/ - - /** - * 检查是否被频率限制 - * - * @return array{allowed: bool, remaining: int} - */ - private function check_error_frequency(string $key): array { - $count_key = 'aksrt_sms_err_' . $key; - $time_key = 'aksrt_sms_err_ts_' . $key; - - $count = (int) get_transient($count_key); - $last = (int) get_transient($time_key); - - if ($count <= 0) { - return ['allowed' => true, 'remaining' => 0]; - } - - // 超过 10 次错误:3 分钟冷却 - if ($count >= 10) { - $cooldown = 180; - $elapsed = time() - $last; - if ($elapsed < $cooldown) { - return ['allowed' => false, 'remaining' => $cooldown - $elapsed]; - } - // 冷却结束,重置 - $this->reset_error_frequency($key); - return ['allowed' => true, 'remaining' => 0]; - } - - // 超过 5 次错误:30 秒冷却 - if ($count >= 5) { - $cooldown = 30; - $elapsed = time() - $last; - if ($elapsed < $cooldown) { - return ['allowed' => false, 'remaining' => $cooldown - $elapsed]; - } - } - - return ['allowed' => true, 'remaining' => 0]; - } - - /** - * 记录一次错误 - */ - private function record_error(string $key): void { - $count_key = 'aksrt_sms_err_' . $key; - $time_key = 'aksrt_sms_err_ts_' . $key; - - $count = (int) get_transient($count_key); - set_transient($count_key, $count + 1, HOUR_IN_SECONDS); - set_transient($time_key, time(), HOUR_IN_SECONDS); - } - - /** - * 重置错误计数 - */ - private function reset_error_frequency(string $key): void { - delete_transient('aksrt_sms_err_' . $key); - delete_transient('aksrt_sms_err_ts_' . $key); - } - - /* ------------------------------------------------------------------ - * AJAX Handlers - * ----------------------------------------------------------------*/ - - /** - * AJAX: Test SMS provider (admin only). - */ - public function ajax_test_provider(): void { - if (!current_user_can('manage_options')) { - wp_send_json_error(['message' => __('权限不足', 'aksrt')]); - } - - $phone = sanitize_text_field((string) ($_POST['phone'] ?? '')); - if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { - wp_send_json_error(['message' => __('手机号格式不正确', 'aksrt')]); - } - - $result = $this->test_provider($phone); - wp_send_json($result); - } - - /** - * AJAX: Get SMS provider status (admin only). - */ - public function ajax_provider_status(): void { - if (!current_user_can('manage_options')) { - wp_send_json_error(['message' => __('权限不足', 'aksrt')]); - } - - wp_send_json_success($this->get_provider_status()); - } - - /** - * 获取 provider 真实运行时状态 - */ - public function get_provider_status(): array { - $config = $this->get_provider_config(); - $provider_key = sanitize_key((string) ($config['sms_provider'] ?? '')); - - $status = [ - 'enabled' => !empty($config['sms_enabled']), - 'provider' => $provider_key ?: 'none', - 'configured' => false, - 'available' => false, - 'verified' => false, - 'transport' => '', - 'missing_fields' => [], - 'code_length' => max(4, min(8, (int) ($config['sms_code_length'] ?? 6))), - 'code_expire' => max(60, min(600, (int) ($config['sms_code_expire'] ?? 300))), - 'rate_limit' => max(10, (int) ($config['sms_rate_limit'] ?? 60)), - 'daily_limit' => max(1, (int) ($config['sms_daily_limit'] ?? 10)), - 'notify_bind' => !empty($config['sms_notify_bind']), - 'notify_login' => !empty($config['sms_notify_login']), - ]; - - $provider = SMSProviderFactory::get($provider_key); - if ($provider !== null) { - $provider_status = $provider->get_status($config); - $status['configured'] = !empty($provider_status['configured']); - $status['available'] = !empty($provider_status['available']); - $status['verified'] = !empty($provider_status['verified']); - $status['transport'] = (string) ($provider_status['transport'] ?? ''); - $status['missing_fields'] = (array) ($provider_status['missing_fields'] ?? []); - } - - return $status; - } - - /** - * Test provider connectivity (admin only). - */ - public function test_provider(string $phone): array { - $config = $this->get_provider_config(); - if (empty($config['sms_enabled'])) { - return ['success' => false, 'message' => __('短信服务未启用', 'aksrt')]; - } - - $provider_key = sanitize_key((string) ($config['sms_provider'] ?? '')); - if ($provider_key === '') { - return ['success' => false, 'message' => __('未配置短信供应商', 'aksrt')]; - } - - $provider = SMSProviderFactory::get($provider_key); - if ($provider === null) { - return ['success' => false, 'message' => sprintf(__('短信供应商 "%s" 不可用', 'aksrt'), $provider_key)]; - } - - $missing = $provider->validate_config($config); - if (!empty($missing)) { - return ['success' => false, 'message' => sprintf(__('缺少配置项: %s', 'aksrt'), implode(', ', $missing))]; - } - - return $this->send_code($phone); - } } diff --git a/src/Sms/SMSProviderAliyun.php b/src/Sms/SMSProviderAliyun.php index 69a1cda5..1ae29cc6 100644 --- a/src/Sms/SMSProviderAliyun.php +++ b/src/Sms/SMSProviderAliyun.php @@ -10,45 +10,43 @@ defined('ABSPATH') || exit; class SMSProviderAliyun implements ProviderInterface { public function get_key(): string { - return 'aliyun'; - } - - public function get_label(): string { - return __('阿里云短信', 'aksrt'); + return 'ali'; } public function validate_config(array $config): array { $missing = []; - if (empty($config['sms_access_key'])) $missing[] = 'sms_access_key'; - if (empty($config['sms_access_secret'])) $missing[] = 'sms_access_secret'; - if (empty($config['sms_sign_name'])) $missing[] = 'sms_sign_name'; - if (empty($config['sms_template_code'])) $missing[] = 'sms_template_code'; + if (empty($config['sms_ali_keyid'])) $missing[] = 'sms_ali_keyid'; + if (empty($config['sms_ali_keysecret'])) $missing[] = 'sms_ali_keysecret'; + if (empty($config['sms_ali_sign_name'])) $missing[] = 'sms_ali_sign_name'; + if (($config['sms_ali_api_type'] ?? 'sms') !== 'verify' && empty($config['sms_ali_template_code'])) { + $missing[] = 'sms_ali_template_code'; + } return $missing; } public function send_code(string $phone, string $code, array $config): array { - $access_key = (string) ($config['sms_access_key'] ?? ''); - $access_secret = (string) ($config['sms_access_secret'] ?? ''); - $sign_name = (string) ($config['sms_sign_name'] ?? ''); - $template_code = (string) ($config['sms_template_code'] ?? ''); + $access_key = (string) ($config['sms_ali_keyid'] ?? ''); + $access_secret = (string) ($config['sms_ali_keysecret'] ?? ''); + $sign_name = (string) ($config['sms_ali_sign_name'] ?? ''); $region_id = (string) ($config['sms_region_id'] ?? 'cn-hangzhou'); + $verify_api = ($config['sms_ali_api_type'] ?? 'sms') === 'verify'; + $action = $verify_api ? 'SendSmsVerifyCode' : 'SendSms'; + $params = [ + ($verify_api ? 'PhoneNumber' : 'PhoneNumbers') => $phone, + 'SignName' => $sign_name, + 'TemplateCode' => $verify_api ? '100001' : (string) ($config['sms_ali_template_code'] ?? ''), + 'TemplateParam' => wp_json_encode($verify_api ? ['code' => $code, 'min' => '30'] : ['code' => $code]), + ]; + $domain = $verify_api ? 'dypnsapi.aliyuncs.com' : 'dysmsapi.aliyuncs.com'; - $params = [ - 'PhoneNumbers' => $phone, - 'SignName' => $sign_name, - 'TemplateCode' => $template_code, - 'TemplateParam' => wp_json_encode(['code' => $code]), - ]; - - $result = $this->request($access_key, $access_secret, $region_id, $params); + $result = $this->request($access_key, $access_secret, $region_id, $domain, $action, $params); if (is_wp_error($result)) { $this->log('error', $phone, $result->get_error_message()); return ['success' => false, 'message' => $result->get_error_message(), 'transport' => 'signed_post']; } - // 对齐 legacy 成功判断:检查 BizId 或 Code === 'OK' - if (!empty($result['BizId']) || (isset($result['Code']) && $result['Code'] === 'OK')) { + if (($verify_api && ($result['Code'] ?? '') === 'OK') || (!$verify_api && (!empty($result['BizId']) || ($result['Code'] ?? '') === 'OK'))) { $this->log('sent', $phone, 'OK'); return ['success' => true, 'message' => __('验证码已发送', 'aksrt'), 'transport' => 'signed_post']; } @@ -64,8 +62,8 @@ class SMSProviderAliyun implements ProviderInterface { return [ 'provider' => $this->get_key(), 'configured' => empty($missing), - 'available' => !empty($config['sms_enabled']) && empty($missing), - 'verified' => !empty($config['sms_aliyun_verified']), + 'available' => ($config['sms_sdk'] ?? 'null') === $this->get_key() && empty($missing), + 'verified' => false, 'transport' => 'signed_post', 'missing_fields' => $missing, ]; @@ -81,7 +79,7 @@ class SMSProviderAliyun implements ProviderInterface { * * @return array|\WP_Error */ - private function request(string $access_key, string $access_secret, string $region_id, array $biz_params) { + private function request(string $access_key, string $access_secret, string $region_id, string $domain, string $action, array $biz_params) { if (!class_exists('AKSRT\\Sms\\SMSSignatureHelper')) { return new \WP_Error('sms_helper_missing', '签名助手类未加载'); } @@ -91,10 +89,10 @@ class SMSProviderAliyun implements ProviderInterface { return $helper->request( $access_key, $access_secret, - 'dysmsapi.aliyuncs.com', + $domain, array_merge($biz_params, [ 'RegionId' => $region_id, - 'Action' => 'SendSms', + 'Action' => $action, 'Version' => '2017-05-25', ]), true // HTTPS diff --git a/src/Sms/SMSProviderCustom.php b/src/Sms/SMSProviderCustom.php deleted file mode 100644 index 77d82b93..00000000 --- a/src/Sms/SMSProviderCustom.php +++ /dev/null @@ -1,166 +0,0 @@ - false, 'message' => __('自定义接口 URL 未配置', 'aksrt'), 'transport' => 'custom_http']; - } - - // 替换模板变量 - $replacements = [ - '{phone}' => $phone, - '{code}' => $code, - '{time}' => (string) time(), - ]; - - $body = str_replace(array_keys($replacements), array_values($replacements), $body_tpl); - - // 解析自定义 headers - $headers = ['Accept' => 'application/json']; - if ($headers_raw !== '') { - foreach (explode("\n", $headers_raw) as $line) { - $line = trim($line); - if ($line === '') continue; - $parts = explode(':', $line, 2); - if (count($parts) === 2) { - $headers[trim($parts[0])] = trim(str_replace(array_keys($replacements), array_values($replacements), $parts[1])); - } - } - } - - // 鉴权 - if ($auth_key !== '' && $auth_value !== '') { - $replaced_value = str_replace(array_keys($replacements), array_values($replacements), $auth_value); - if ($auth_type === 'header') { - $headers[$auth_key] = $replaced_value; - } - } - - $args = [ - 'timeout' => 10, - 'headers' => $headers, - ]; - - if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) { - $args['body'] = $body; - $result = wp_remote_request($url, array_merge($args, ['method' => $method])); - } else { - // GET — append body as query params - $url_with_params = add_query_arg(json_decode($body, true) ?: [], $url); - $result = wp_remote_get($url_with_params, $args); - } - - if (is_wp_error($result)) { - $this->log('error', $phone, $result->get_error_message()); - return ['success' => false, 'message' => $result->get_error_message(), 'transport' => 'custom_http']; - } - - $status_code = wp_remote_retrieve_response_code($result); - $response_body = wp_remote_retrieve_body($result); - - // 判定成功规则 - if ($success_rule !== '') { - $matched = $this->check_success_rule($success_rule, $status_code, $response_body); - if ($matched) { - $this->log('sent', $phone, "status={$status_code}"); - return ['success' => true, 'message' => __('验证码已发送', 'aksrt'), 'transport' => 'custom_http']; - } - $this->log('error', $phone, "status={$status_code}, rule not matched"); - return ['success' => false, 'message' => sprintf(__('自定义接口返回异常 (HTTP %d)', 'aksrt'), $status_code), 'transport' => 'custom_http']; - } - - // 默认:HTTP 2xx 视为成功 - if ($status_code >= 200 && $status_code < 300) { - $this->log('sent', $phone, "status={$status_code}"); - return ['success' => true, 'message' => __('验证码已发送', 'aksrt'), 'transport' => 'custom_http']; - } - - $this->log('error', $phone, "status={$status_code}"); - return ['success' => false, 'message' => sprintf(__('自定义接口返回 HTTP %d', 'aksrt'), $status_code), 'transport' => 'custom_http']; - } - - public function get_status(array $config): array { - $missing = $this->validate_config($config); - return [ - 'provider' => $this->get_key(), - 'configured' => empty($missing), - 'available' => !empty($config['sms_enabled']) && empty($missing), - 'verified' => !empty($config['sms_custom_verified']), - 'transport' => 'custom_http', - 'missing_fields' => $missing, - ]; - } - - /** - * 校验成功规则 - * - * 支持格式: - * - "status:200" — HTTP 状态码匹配 - * - "json:code=0" — JSON 响应中字段匹配 - * - "contains:success" — 响应体包含字符串 - */ - private function check_success_rule(string $rule, int $status_code, string $body): bool { - $parts = explode(':', $rule, 2); - if (count($parts) < 2) return false; - - $type = trim($parts[0]); - $value = trim($parts[1]); - - switch ($type) { - case 'status': - return (string) $status_code === $value; - - case 'json': - $json = json_decode($body, true); - if (!is_array($json)) return false; - $kv = explode('=', $value, 2); - if (count($kv) !== 2) return false; - $key = trim($kv[0]); - $expected = trim($kv[1]); - return (string) ($json[$key] ?? '') === $expected; - - case 'contains': - return strpos($body, $value) !== false; - - default: - return false; - } - } - - private function log(string $level, string $phone, string $message): void { - if (!defined('WP_DEBUG') || !WP_DEBUG) return; - $masked = substr($phone, 0, 3) . '****' . substr($phone, -4); - error_log(sprintf('[AKSRT SMS] [custom] [%s] Phone: %s — %s', $level, $masked, $message)); - } -} diff --git a/src/Sms/SMSProviderFactory.php b/src/Sms/SMSProviderFactory.php index 06985085..dd4613eb 100644 --- a/src/Sms/SMSProviderFactory.php +++ b/src/Sms/SMSProviderFactory.php @@ -29,36 +29,17 @@ class SMSProviderFactory { return $instance; } - /** - * 获取所有已注册供应商 - * - * @return ProviderInterface[] - */ - public static function all(): array { - $keys = ['aliyun', 'tencent', 'huawei', 'custom']; - $result = []; - foreach ($keys as $key) { - $provider = self::get($key); - if ($provider !== null) { - $result[$key] = $provider; - } - } - return $result; - } - /** * 创建 adapter 实例 */ private static function create(string $key): ?ProviderInterface { switch ($key) { - case 'aliyun': + case 'ali': return class_exists('AKSRT\\Sms\\SMSProviderAliyun') ? new SMSProviderAliyun() : null; case 'tencent': return class_exists('AKSRT\\Sms\\SMSProviderTencent') ? new SMSProviderTencent() : null; - case 'huawei': - return class_exists('AKSRT\\Sms\\SMSProviderHuawei') ? new SMSProviderHuawei() : null; - case 'custom': - return class_exists('AKSRT\\Sms\\SMSProviderCustom') ? new SMSProviderCustom() : null; + case 'smsbao': + return class_exists('AKSRT\\Sms\\SMSProviderSmsbao') ? new SMSProviderSmsbao() : null; default: return null; } diff --git a/src/Sms/SMSProviderHuawei.php b/src/Sms/SMSProviderHuawei.php deleted file mode 100644 index 718f694d..00000000 --- a/src/Sms/SMSProviderHuawei.php +++ /dev/null @@ -1,105 +0,0 @@ -request($app_key, $app_secret, $sender, $template_id, $signature, $endpoint, $phone, $code); - - if (is_wp_error($result)) { - $this->log('error', $phone, $result->get_error_message()); - return ['success' => false, 'message' => $result->get_error_message(), 'transport' => 'hw_http']; - } - - $body = json_decode(wp_remote_retrieve_body($result), true); - $code_field = $body['code'] ?? ''; - - if ($code_field === '000000') { - $this->log('sent', $phone, '000000'); - return ['success' => true, 'message' => __('验证码已发送', 'aksrt'), 'transport' => 'hw_http']; - } - - $err_msg = $body['description'] ?? $body['code'] ?? __('未知错误', 'aksrt'); - $this->log('error', $phone, "{$code_field}: {$err_msg}"); - return ['success' => false, 'message' => sprintf('华为云: %s', $err_msg), 'transport' => 'hw_http']; - } - - public function get_status(array $config): array { - $missing = $this->validate_config($config); - return [ - 'provider' => $this->get_key(), - 'configured' => empty($missing), - 'available' => !empty($config['sms_enabled']) && empty($missing), - 'verified' => !empty($config['sms_huawei_verified']), - 'transport' => 'hw_http', - 'missing_fields' => $missing, - ]; - } - - /** - * 华为云 AK/SK 签名请求 - */ - private function request(string $app_key, string $app_secret, string $sender, string $template_id, string $signature, string $endpoint, string $phone, string $code) { - $endpoint = rtrim($endpoint, '/'); - $url = "{$endpoint}/sms/batchSendSms"; - - $body = wp_json_encode([ - 'from' => $sender, - 'to' => [$phone], - 'templateId' => $template_id, - 'templateParas' => [$code], - 'signature' => $signature, - ]); - - $timestamp = gmdate('Y-m-d\TH:i:s\Z'); - $nonce = wp_generate_uuid4(); - - // 简化签名:使用 X-Sms-AppKey 鉴权方式 - return wp_remote_post($url, [ - 'timeout' => 10, - 'headers' => [ - 'Content-Type' => 'application/json', - 'X-Sms-AppKey' => $app_key, - 'X-Sms-AppSecret' => $app_secret, - ], - 'body' => $body, - ]); - } - - private function log(string $level, string $phone, string $message): void { - if (!defined('WP_DEBUG') || !WP_DEBUG) return; - $masked = substr($phone, 0, 3) . '****' . substr($phone, -4); - error_log(sprintf('[AKSRT SMS] [huawei] [%s] Phone: %s — %s', $level, $masked, $message)); - } -} diff --git a/src/Sms/SMSProviderSmsbao.php b/src/Sms/SMSProviderSmsbao.php new file mode 100644 index 00000000..5bbc3ef5 --- /dev/null +++ b/src/Sms/SMSProviderSmsbao.php @@ -0,0 +1,77 @@ + '短信发送成功', + '-1' => '短信宝接口参数不全', + '-2' => '服务器不支持短信请求', + '30' => '短信宝密码错误', + '40' => '短信宝账号不存在', + '41' => '短信宝余额不足', + '42' => '短信宝账号已过期', + '43' => '短信宝 IP 地址受限', + '50' => '短信内容含有敏感词', + '51' => '手机号码不正确', + ]; + + public function get_key(): string { + return 'smsbao'; + } + + public function validate_config(array $config): array { + $missing = []; + if (empty($config['sms_smsbao_username'])) $missing[] = 'sms_smsbao_username'; + if (empty($config['sms_smsbao_api_key']) && empty($config['sms_smsbao_password'])) $missing[] = 'sms_smsbao_api_key 或 sms_smsbao_password'; + $template = (string) ($config['sms_smsbao_template'] ?? ''); + if ($template === '') $missing[] = 'sms_smsbao_template'; + elseif (!str_contains($template, '{code}')) $missing[] = '短信模板中的 {code} 变量'; + return $missing; + } + + public function send_code(string $phone, string $code, array $config): array { + $template = str_replace(['{code}', '{time}'], [$code, '30'], (string) ($config['sms_smsbao_template'] ?? '')); + $password = (string) ($config['sms_smsbao_api_key'] ?? ''); + if ($password === '') { + $password = md5((string) ($config['sms_smsbao_password'] ?? '')); + } + $response = wp_remote_get(add_query_arg([ + 'u' => (string) ($config['sms_smsbao_username'] ?? ''), + 'p' => $password, + 'm' => $phone, + 'c' => $template, + ], 'https://api.smsbao.com/sms'), ['timeout' => 10]); + if (is_wp_error($response)) { + return ['success' => false, 'message' => $response->get_error_message(), 'transport' => 'smsbao_http']; + } + + if (wp_remote_retrieve_response_code($response) !== 200) { + return ['success' => false, 'message' => __('短信宝接口请求失败', 'aksrt'), 'transport' => 'smsbao_http']; + } + $result = trim(wp_remote_retrieve_body($response)); + if ($result === '0') { + return ['success' => true, 'message' => __('验证码短信已发送', 'aksrt'), 'transport' => 'smsbao_http']; + } + $message = self::ERROR_MESSAGES[$result] ?? __('短信宝发送失败', 'aksrt'); + return ['success' => false, 'message' => $message . ' | ' . __('错误码:', 'aksrt') . ' ' . $result, 'transport' => 'smsbao_http']; + } + + public function get_status(array $config): array { + $missing = $this->validate_config($config); + return [ + 'provider' => $this->get_key(), + 'configured' => empty($missing), + 'available' => ($config['sms_sdk'] ?? 'null') === $this->get_key() && empty($missing), + 'verified' => false, + 'transport' => 'smsbao_http', + 'missing_fields' => $missing, + ]; + } +} diff --git a/src/Sms/SMSProviderTencent.php b/src/Sms/SMSProviderTencent.php index abaedb7a..2be86cd1 100644 --- a/src/Sms/SMSProviderTencent.php +++ b/src/Sms/SMSProviderTencent.php @@ -13,47 +13,43 @@ class SMSProviderTencent implements ProviderInterface { return 'tencent'; } - public function get_label(): string { - return __('腾讯云短信', 'aksrt'); - } - public function validate_config(array $config): array { $missing = []; - if (empty($config['sms_access_key'])) $missing[] = 'sms_access_key'; - if (empty($config['sms_access_secret'])) $missing[] = 'sms_access_secret'; if (empty($config['sms_tencent_app_id'])) $missing[] = 'sms_tencent_app_id'; - if (empty($config['sms_sign_name'])) $missing[] = 'sms_sign_name'; - if (empty($config['sms_template_code'])) $missing[] = 'sms_template_code'; + if (empty($config['sms_tencent_app_key'])) $missing[] = 'sms_tencent_app_key'; + if (empty($config['sms_tencent_sign_name'])) $missing[] = 'sms_tencent_sign_name'; + if (empty($config['sms_tencent_template_id'])) $missing[] = 'sms_tencent_template_id'; return $missing; } public function send_code(string $phone, string $code, array $config): array { - $secret_id = (string) ($config['sms_access_key'] ?? ''); - $secret_key = (string) ($config['sms_access_secret'] ?? ''); $app_id = (string) ($config['sms_tencent_app_id'] ?? ''); - $sign_name = (string) ($config['sms_sign_name'] ?? ''); - $template_id = (string) ($config['sms_template_code'] ?? ''); - $region = (string) ($config['sms_tencent_region'] ?? 'ap-guangzhou'); + $app_key = (string) ($config['sms_tencent_app_key'] ?? ''); + $sign_name = (string) ($config['sms_tencent_sign_name'] ?? ''); + $template_id = (string) ($config['sms_tencent_template_id'] ?? ''); - $result = $this->request($secret_id, $secret_key, $app_id, $sign_name, $template_id, $region, $phone, $code); + $result = $this->request($app_id, $app_key, $sign_name, $template_id, $phone, $code); if (is_wp_error($result)) { $this->log('error', $phone, $result->get_error_message()); - return ['success' => false, 'message' => $result->get_error_message(), 'transport' => 'tc3_http']; + return ['success' => false, 'message' => $result->get_error_message(), 'transport' => 'qcloudsms_http']; } - $body = json_decode(wp_remote_retrieve_body($result), true); - $response = $body['Response'] ?? []; + $response = json_decode(wp_remote_retrieve_body($result), true); + $response = is_array($response) ? $response : []; + if (wp_remote_retrieve_response_code($result) !== 200) { + return ['success' => false, 'message' => (string) ($response['errmsg'] ?? __('腾讯云接口请求失败', 'aksrt')), 'transport' => 'qcloudsms_http']; + } - if (isset($response['SendStatusSet'][0]['Code']) && $response['SendStatusSet'][0]['Code'] === 'Ok') { + if (isset($response['result']) && (int) $response['result'] === 0) { $this->log('sent', $phone, 'Ok'); - return ['success' => true, 'message' => __('验证码已发送', 'aksrt'), 'transport' => 'tc3_http']; + return ['success' => true, 'message' => __('验证码已发送', 'aksrt'), 'transport' => 'qcloudsms_http']; } - $err_code = $response['Error']['Code'] ?? ($response['SendStatusSet'][0]['Code'] ?? 'Unknown'); - $err_msg = $response['Error']['Message'] ?? ($response['SendStatusSet'][0]['Message'] ?? __('未知错误', 'aksrt')); + $err_code = $response['result'] ?? 'Unknown'; + $err_msg = $response['errmsg'] ?? __('未知错误', 'aksrt'); $this->log('error', $phone, "{$err_code}: {$err_msg}"); - return ['success' => false, 'message' => sprintf('腾讯云: %s', $err_msg), 'transport' => 'tc3_http']; + return ['success' => false, 'message' => sprintf('腾讯云: %s', $err_msg), 'transport' => 'qcloudsms_http']; } public function get_status(array $config): array { @@ -61,77 +57,41 @@ class SMSProviderTencent implements ProviderInterface { return [ 'provider' => $this->get_key(), 'configured' => empty($missing), - 'available' => !empty($config['sms_enabled']) && empty($missing), - 'verified' => !empty($config['sms_tencent_verified']), - 'transport' => 'tc3_http', + 'available' => ($config['sms_sdk'] ?? 'null') === $this->get_key() && empty($missing), + 'verified' => false, + 'transport' => 'qcloudsms_http', 'missing_fields' => $missing, ]; } /** - * TC3-HMAC-SHA256 签名请求 + * 腾讯云 AppID/AppKey 模板单发请求 */ - private function request(string $secret_id, string $secret_key, string $app_id, string $sign_name, string $template_id, string $region, string $phone, string $code) { - $service = 'sms'; - $host = "{$service}.tencentcloudapi.com"; - $action = 'SendSms'; - $version = '2021-01-11'; - $endpoint = "https://{$host}"; - - $payload = wp_json_encode([ - 'PhoneNumberSet' => [$phone], - 'SmsSdkAppId' => $app_id, - 'SignName' => $sign_name, - 'TemplateId' => $template_id, - 'TemplateParamSet' => [$code], - ]); - - $timestamp = time(); - $date = gmdate('Y-m-d', $timestamp); - - // Step 1: CanonicalRequest - $http_request_method = 'POST'; - $canonical_uri = '/'; - $canonical_querystring = ''; - $content_type = 'application/json; charset=utf-8'; - $canonical_headers = "content-type:{$content_type}\nhost:{$host}\nx-tc-action:" . strtolower($action) . "\n"; - $signed_headers = 'content-type;host;x-tc-action'; - $hashed_payload = hash('sha256', $payload); - $canonical_request = "{$http_request_method}\n{$canonical_uri}\n{$canonical_querystring}\n{$canonical_headers}\n{$signed_headers}\n{$hashed_payload}"; - - // Step 2: StringToSign - $algorithm = 'TC3-HMAC-SHA256'; - $credential_scope = "{$date}/{$service}/tc3_request"; - $hashed_request = hash('sha256', $canonical_request); - $string_to_sign = "{$algorithm}\n{$timestamp}\n{$credential_scope}\n{$hashed_request}"; - - // Step 3: Signature - $secret_date = hash_hmac('sha256', $date, "TC3{$secret_key}", true); - $secret_service = hash_hmac('sha256', $service, $secret_date, true); - $secret_signing = hash_hmac('sha256', 'tc3_request', $secret_service, true); - $signature = hash_hmac('sha256', $string_to_sign, $secret_signing); - - // Step 4: Authorization header - $authorization = "{$algorithm} Credential={$secret_id}/{$credential_scope}, SignedHeaders={$signed_headers}, Signature={$signature}"; - - return wp_remote_post($endpoint, [ - 'timeout' => 10, - 'headers' => [ - 'Authorization' => $authorization, - 'Content-Type' => $content_type, - 'Host' => $host, - 'X-TC-Action' => $action, - 'X-TC-Version' => $version, - 'X-TC-Timestamp' => (string) $timestamp, - 'X-TC-Region' => $region, - ], - 'body' => $payload, - ]); + private function request(string $app_id, string $app_key, string $sign_name, string $template_id, string $phone, string $code) { + $random = random_int(100000, 999999); + $timestamp = time(); + $signature = hash('sha256', 'appkey=' . $app_key . '&random=' . $random . '&time=' . $timestamp . '&mobile=' . $phone); + $url = add_query_arg(['sdkappid' => $app_id, 'random' => $random], 'https://yun.tim.qq.com/v5/tlssmssvr/sendsms'); + $payload = wp_json_encode([ + 'tel' => ['nationcode' => '86', 'mobile' => $phone], + 'sig' => $signature, + 'tpl_id' => $template_id, + 'params' => [$code, '30'], + 'sign' => $sign_name, + 'time' => $timestamp, + 'extend' => '', + 'ext' => '', + ]); + return wp_remote_post($url, [ + 'timeout' => 10, + 'headers' => ['Content-Type' => 'application/json; charset=utf-8'], + 'body' => $payload, + ]); } private function log(string $level, string $phone, string $message): void { if (!defined('WP_DEBUG') || !WP_DEBUG) return; $masked = substr($phone, 0, 3) . '****' . substr($phone, -4); - error_log(sprintf('[AKSRT SMS] [tencent] [%s] Phone: %s — %s', $level, $masked, $message)); + error_log(sprintf('[AKSRT SMS] [tencent] [%s] Phone: %s - %s', $level, $masked, $message)); } }