Files
wordpress-theme-aksrt/src/Sms/SMS.php
T
kate522 9375b1c30c refactor(assets): vendor 资产迁入标准目录并全仓去除 zibll 标识
- assets/vendor 全量迁移: fontawesome5→assets/fontawesome5, css→assets/css,
  js→assets/js/runtime(避开 14 个同名自有文件), img 合并去重→assets/img,
  fonts→assets/fonts; 删除 assets/vendor
- libs 五个与 widgets/third-party 字节相同的库不重复入库, loader paths 改指向
- 运行时切换: _win.uri=assets 根, loader baseUrl=/js/runtime, enlighter 注入
  路径同步; PHP 7 个引用文件切换并改名 $zibll_*→$runtime_*
- 去品牌: Qinver 头注释/品牌 console.log/演示链接清除, 默认文案中性化
- 功能标识符改名: zib__→aksrt__, zib_ajax→aksrt_ajax, window.zib→
  window.aksrtLegacy, zibllblock/*→aksrtblock/*, .zib-widget→.aksrt-panel,
  .zib-slider→.aksrt-slider, zib_menu_options meta 键等
- 自有 CSS 改名: legacy-utilities/compat-vars/footer shell
- admin SPA 重建; 契约测试同步(路径/断言/文件更名)
- 例外保留: tests/visual 对照环境与 fixtures 前缀(外部 Zibll 产品引用)

验证: php -l 133 个改动文件通过; runtime 86 个 JS node --check 通过;
契约测试 68/70(2 个失败经 stash 对照确认为存量问题)
2026-08-22 16:16:30 +08:00

406 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Sms;
defined('ABSPATH') || exit;
class SMS {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
public function init(): void {
add_action('wp_ajax_aksrt_send_sms_code', [$this, 'ajax_send_code']);
add_action('wp_ajax_nopriv_aksrt_send_sms_code', [$this, 'ajax_send_code']);
add_action('wp_ajax_aksrt_sms_test', [$this, 'ajax_test_provider']);
add_action('wp_ajax_aksrt_sms_status', [$this, 'ajax_provider_status']);
}
/**
* 获取短信供应商配置
*/
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' => ''];
}
/**
* 获取当前 provider adapter
*/
public function get_provider(): ?ProviderInterface {
$config = $this->get_provider_config();
$key = sanitize_key((string) ($config['sms_provider'] ?? ''));
return SMSProviderFactory::get($key);
}
/**
* 发送短信验证码
*
* @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 (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))];
}
// 频率限制:间隔限制(对齐 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,
];
}
// 每日限制
$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')];
}
$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)));
// 存储验证码(绑定手机号)
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);
// 委托 provider 发送
$result = $provider->send_code($phone, $code, $config);
if (!empty($result['success'])) {
return [
'success' => true,
'message' => $result['message'] ?? __('验证码已发送', 'aksrt'),
'remaining_time' => $rate_limit,
];
}
// 发送失败:删除已存储的验证码,避免无效等待
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')];
}
/**
* 兼容旧接口:verify_code 返回 bool
*/
public function verify_code_bool(string $phone, string $code): bool {
$result = $this->verify_code($phone, $code);
return !empty($result['success']);
}
/**
* 生成验证码
*/
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
* ----------------------------------------------------------------*/
/**
* 手机号格式校验(对齐 legacy:支持中国号和国际号)
*/
private function validate_phone(string $phone): bool {
$config = $this->get_provider_config();
$limit_ch = !isset($config['sms_number_limit_ch']) || !empty($config['sms_number_limit_ch']);
if ($limit_ch) {
// 仅中国大陆手机号
return (bool) preg_match('/^1[3-9]\d{9}$/', $phone);
}
// 国际手机号:5 位以上数字,可选 + 前缀
return (bool) preg_match('/^(?:\d{5,}|\+\d{6,})$/', $phone);
}
/**
* AJAX 发送验证码
*/
public function ajax_send_code(): void {
check_ajax_referer('aksrt_sms', '_nonce');
$phone = sanitize_text_field((string) ($_POST['phone'] ?? ''));
if (!$this->validate_phone($phone)) {
wp_send_json_error(['message' => __('手机号格式不正确', 'aksrt')]);
}
$scene = sanitize_key((string) ($_POST['scene'] ?? 'register'));
if (!array_key_exists($scene, function_exists('aksrt_captcha_scenes') ? aksrt_captcha_scenes() : [])) {
$scene = 'register';
}
if (function_exists('aksrt_require_captcha')) {
$captcha_result = aksrt_require_captcha($scene, wp_unslash($_POST));
if ($captcha_result !== true) {
wp_send_json_error(['message' => $captcha_result ?: __('人机验证失败', 'aksrt')]);
}
}
$result = $this->send_code($phone);
if ($result['success']) {
wp_send_json_success([
'message' => $result['message'],
'remaining_time' => $result['remaining_time'] ?? 60,
]);
}
$error_data = ['message' => $result['message']];
if (isset($result['remaining_time'])) {
$error_data['remaining_time'] = $result['remaining_time'];
}
wp_send_json_error($error_data);
}
/**
* 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);
}
}