feat: 手动实现表单邮箱验证码功能

This commit is contained in:
2026-08-31 22:55:48 +08:00
parent d860a34680
commit 092f89b2a8
4 changed files with 284 additions and 0 deletions
@@ -0,0 +1,253 @@
<?php
/**
* 招新报名表单邮箱 OTP:前台发码、限流、服务端校验和一次性消费。
*/
if (!defined('ABSPATH')) {
exit;
}
function itstudio_join_otp_get_config() {
$settings = itstudio_join_get_settings();
$form_id = absint($settings['otp_form_id'] ?? 0);
if ($form_id <= 0 && preg_match('/\[formidable\s+[^\]]*\bid=["\']?(\d+)/i', (string) ($settings['signup_form_shortcode'] ?? ''), $matches)) {
$form_id = absint($matches[1]);
}
return array(
'enabled' => !empty($settings['otp_enabled']),
'form_id' => $form_id,
'email_field_id' => absint($settings['otp_email_field_id'] ?? 0),
'code_field_id' => absint($settings['otp_code_field_id'] ?? 0),
'ttl' => 10 * MINUTE_IN_SECONDS,
'cooldown' => 60,
'max_attempts' => 5,
);
}
function itstudio_join_otp_is_configured() {
$config = itstudio_join_otp_get_config();
return !empty($config['enabled'])
&& $config['form_id'] > 0
&& $config['email_field_id'] > 0
&& $config['code_field_id'] > 0;
}
function itstudio_join_otp_normalize_email($email) {
return strtolower(trim(sanitize_email((string) $email)));
}
function itstudio_join_otp_get_client_ip() {
return isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR'])) : '';
}
function itstudio_join_otp_key($email, $form_id) {
return 'itstudio_join_otp_' . md5((string) $form_id . '|' . itstudio_join_otp_normalize_email($email));
}
function itstudio_join_otp_ip_key($ip) {
return 'itstudio_join_otp_ip_' . md5((string) $ip);
}
function itstudio_join_otp_hash_code($email, $code) {
return hash_hmac('sha256', itstudio_join_otp_normalize_email($email) . '|' . (string) $code, wp_salt('auth'));
}
function itstudio_join_otp_random_code() {
return (string) wp_rand(100000, 999999);
}
function itstudio_join_otp_json_error($message_cn, $message_en, $status = 400, $extra = array()) {
wp_send_json_error(
array_merge(
array(
'message_cn' => $message_cn,
'message_en' => $message_en,
),
$extra
),
$status
);
}
function itstudio_join_otp_send_ajax() {
if (!check_ajax_referer('itstudio_join_otp', 'nonce', false)) {
itstudio_join_otp_json_error('请求已过期,请刷新页面后重试。', 'Request expired. Please refresh the page and try again.', 403);
}
$config = itstudio_join_otp_get_config();
if (!itstudio_join_otp_is_configured()) {
itstudio_join_otp_json_error('邮箱验证码尚未完成配置,请联系管理员。', 'Email verification is not configured yet. Please contact the administrator.', 503);
}
$email = itstudio_join_otp_normalize_email(isset($_POST['email']) ? wp_unslash($_POST['email']) : '');
if ($email === '' || !is_email($email)) {
itstudio_join_otp_json_error('请输入有效的邮箱地址。', 'Please enter a valid email address.', 400);
}
$ip = itstudio_join_otp_get_client_ip();
$otp_key = itstudio_join_otp_key($email, $config['form_id']);
$existing = get_transient($otp_key);
$now = time();
if (is_array($existing) && !empty($existing['last_sent']) && ($now - (int) $existing['last_sent']) < $config['cooldown']) {
$wait = max(1, $config['cooldown'] - ($now - (int) $existing['last_sent']));
itstudio_join_otp_json_error("请等待 {$wait} 秒后再发送。", "Please wait {$wait} seconds before requesting another code.", 429, array('wait' => $wait));
}
if ($ip !== '') {
$ip_key = itstudio_join_otp_ip_key($ip);
$ip_last_sent = (int) get_transient($ip_key);
if ($ip_last_sent > 0 && ($now - $ip_last_sent) < 10) {
$wait = max(1, 10 - ($now - $ip_last_sent));
itstudio_join_otp_json_error("请求过于频繁,请等待 {$wait} 秒。", "Too many requests. Please wait {$wait} seconds.", 429, array('wait' => $wait));
}
set_transient($ip_key, $now, 10);
}
$code = itstudio_join_otp_random_code();
$payload = array(
'code_hash' => itstudio_join_otp_hash_code($email, $code),
'expires' => $now + $config['ttl'],
'last_sent' => $now,
'attempts' => 0,
'form_id' => $config['form_id'],
'email' => $email,
);
set_transient($otp_key, $payload, $config['ttl']);
$site_name = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
$subject = sprintf('%s 报名邮箱验证码', $site_name !== '' ? $site_name : '爱特工作室');
$message = "您好!\n\n您的报名邮箱验证码是:{$code}\n\n验证码 10 分钟内有效,且只能使用一次。如非本人操作,请忽略此邮件。\n\n{$site_name}";
$sent = wp_mail($email, $subject, $message, array('Content-Type: text/plain; charset=UTF-8'));
if (!$sent) {
delete_transient($otp_key);
itstudio_join_otp_json_error('邮件发送失败,请稍后重试。', 'The email could not be sent. Please try again later.', 500);
}
wp_send_json_success(
array(
'message_cn' => '验证码已发送,请检查邮箱及垃圾邮件。',
'message_en' => 'Verification code sent. Please check your inbox.',
'cooldown' => $config['cooldown'],
'ttl' => $config['ttl'],
)
);
}
add_action('wp_ajax_itstudio_join_otp_send', 'itstudio_join_otp_send_ajax');
add_action('wp_ajax_nopriv_itstudio_join_otp_send', 'itstudio_join_otp_send_ajax');
function itstudio_join_otp_validate_field($errors, $posted_field, $posted_value) {
if (!is_object($posted_field)) {
return $errors;
}
$config = itstudio_join_otp_get_config();
if (!itstudio_join_otp_is_configured()
|| (int) ($posted_field->form_id ?? 0) !== $config['form_id']
|| (int) ($posted_field->id ?? 0) !== $config['code_field_id']) {
return $errors;
}
$email = itstudio_join_otp_normalize_email(isset($_POST['item_meta'][$config['email_field_id']]) ? wp_unslash($_POST['item_meta'][$config['email_field_id']]) : '');
$code = is_scalar($posted_value) ? trim((string) $posted_value) : '';
$field_key = 'field' . $config['code_field_id'];
if ($email === '' || !is_email($email)) {
$errors[$field_key] = '请输入有效的邮箱并先获取验证码。';
return $errors;
}
if (!preg_match('/^\d{6}$/', $code)) {
$errors[$field_key] = '请输入 6 位数字验证码。';
return $errors;
}
$otp_key = itstudio_join_otp_key($email, $config['form_id']);
$payload = get_transient($otp_key);
if (!is_array($payload) || empty($payload['code_hash']) || empty($payload['expires'])) {
$errors[$field_key] = '验证码不存在或已失效,请重新获取。';
return $errors;
}
if (time() > (int) $payload['expires']) {
delete_transient($otp_key);
$errors[$field_key] = '验证码已过期,请重新获取。';
return $errors;
}
if ((int) ($payload['attempts'] ?? 0) >= $config['max_attempts']) {
delete_transient($otp_key);
$errors[$field_key] = '验证码尝试次数过多,请重新获取。';
return $errors;
}
if (!hash_equals((string) $payload['code_hash'], itstudio_join_otp_hash_code($email, $code))) {
$payload['attempts'] = (int) ($payload['attempts'] ?? 0) + 1;
$remaining = max(0, (int) $payload['expires'] - time());
if ($remaining > 0) {
set_transient($otp_key, $payload, $remaining);
}
$errors[$field_key] = '验证码错误,请重试。';
return $errors;
}
$payload['verified_at'] = time();
$remaining = max(1, (int) $payload['expires'] - time());
set_transient($otp_key, $payload, $remaining);
return $errors;
}
add_filter('frm_validate_field_entry', 'itstudio_join_otp_validate_field', 20, 3);
function itstudio_join_otp_consume_after_entry($entry_id, $form_id) {
$config = itstudio_join_otp_get_config();
if (!itstudio_join_otp_is_configured() || (int) $form_id !== $config['form_id']) {
return;
}
$email = itstudio_join_otp_normalize_email(isset($_POST['item_meta'][$config['email_field_id']]) ? wp_unslash($_POST['item_meta'][$config['email_field_id']]) : '');
if ($email !== '' && is_email($email)) {
delete_transient(itstudio_join_otp_key($email, $config['form_id']));
}
}
add_action('frm_after_create_entry', 'itstudio_join_otp_consume_after_entry', 20, 2);
function itstudio_join_otp_enqueue_assets() {
if (!itstudio_is_join_page_context() || !itstudio_join_otp_is_configured() || !shortcode_exists('formidable')) {
return;
}
$config = itstudio_join_otp_get_config();
wp_enqueue_style(
'itstudio-join-otp',
plugins_url('../assets/otp.css', __FILE__),
array('itstudio-join-page'),
ITSTUDIO_RECRUITMENT_VERSION
);
wp_enqueue_script(
'itstudio-join-otp',
plugins_url('../assets/otp.js', __FILE__),
array(),
ITSTUDIO_RECRUITMENT_VERSION,
true
);
wp_localize_script(
'itstudio-join-otp',
'itstudioJoinOtp',
array(
'formId' => $config['form_id'],
'emailFieldId' => $config['email_field_id'],
'codeFieldId' => $config['code_field_id'],
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('itstudio_join_otp'),
'cooldown' => $config['cooldown'],
'labels' => array(
'send' => '发送验证码',
'resend' => '重新发送 (%s秒)',
'sending' => '发送中…',
'emailRequired' => '请先填写邮箱地址。',
'invalidEmail' => '请输入有效的邮箱地址。',
'networkError' => '网络错误,请稍后重试。',
),
)
);
}
add_action('wp_enqueue_scripts', 'itstudio_join_otp_enqueue_assets', 40);
@@ -63,6 +63,10 @@ function itstudio_join_get_default_settings() {
'photo_public_notice' => 0,
'photo_inactive' => 0,
'signup_form_shortcode' => '',
'otp_enabled' => 0,
'otp_form_id' => '',
'otp_email_field_id' => '',
'otp_code_field_id' => '',
);
}
@@ -179,6 +183,10 @@ function itstudio_join_sanitize_settings($input) {
'result_second_interview_records' => itstudio_join_sanitize_records_value($input['result_second_interview_records'] ?? ''),
'result_admission_records' => itstudio_join_sanitize_records_value($input['result_admission_records'] ?? ''),
'signup_form_shortcode' => itstudio_join_sanitize_shortcode_value($input['signup_form_shortcode'] ?? ''),
'otp_enabled' => !empty($input['otp_enabled']) ? 1 : 0,
'otp_form_id' => absint($input['otp_form_id'] ?? 0),
'otp_email_field_id' => absint($input['otp_email_field_id'] ?? 0),
'otp_code_field_id' => absint($input['otp_code_field_id'] ?? 0),
);
foreach (itstudio_join_get_photo_field_map() as $field_key) {
@@ -2385,6 +2393,23 @@ function itstudio_join_render_settings_page() {
<p class="description">示例:<code>[formidable id="12"]</code></p>
</td>
</tr>
<tr>
<th scope="row">邮箱 OTP 验证</th>
<td>
<label for="itstudio_join_otp_enabled">
<input type="checkbox" id="itstudio_join_otp_enabled" name="itstudio_join_settings[otp_enabled]" value="1" <?php checked(!empty($settings['otp_enabled'])); ?>>
在报名表单提交前启用邮箱验证码
</label>
<p class="description">启用后,前台会在指定验证码字段旁自动显示“发送验证码”按钮,不需要手动添加 HTML 按钮。</p>
<p style="margin:10px 0 4px;"><label for="itstudio_join_otp_form_id">Formidable 表单 ID</label></p>
<input type="number" min="1" step="1" id="itstudio_join_otp_form_id" name="itstudio_join_settings[otp_form_id]" value="<?php echo esc_attr((string) ($settings['otp_form_id'] ?? '')); ?>" class="small-text">
<p style="margin:10px 0 4px;"><label for="itstudio_join_otp_email_field_id">邮箱字段 ID</label></p>
<input type="number" min="1" step="1" id="itstudio_join_otp_email_field_id" name="itstudio_join_settings[otp_email_field_id]" value="<?php echo esc_attr((string) ($settings['otp_email_field_id'] ?? '')); ?>" class="small-text">
<p style="margin:10px 0 4px;"><label for="itstudio_join_otp_code_field_id">验证码字段 ID</label></p>
<input type="number" min="1" step="1" id="itstudio_join_otp_code_field_id" name="itstudio_join_settings[otp_code_field_id]" value="<?php echo esc_attr((string) ($settings['otp_code_field_id'] ?? '')); ?>" class="small-text">
<p class="description">验证码字段建议使用 Short Text,设置为仅允许数字、最大长度 6。邮箱字段必须是 Email 类型。验证码有效期 10 分钟,同一邮箱 60 秒内不能重复发送,最多尝试 5 次。</p>
</td>
</tr>
</table>
<?php submit_button('保存设置'); ?>
</form>
@@ -23,7 +23,9 @@ define('ITSTUDIO_RECRUITMENT_PATH', plugin_dir_path(__FILE__));
require_once ITSTUDIO_RECRUITMENT_PATH . 'includes/recruitment-articles.php';
require_once ITSTUDIO_RECRUITMENT_PATH . 'includes/join-system.php';
require_once ITSTUDIO_RECRUITMENT_PATH . 'includes/email-otp.php';
require_once ITSTUDIO_RECRUITMENT_PATH . 'includes/terms.php';
require_once ITSTUDIO_RECRUITMENT_PATH . 'includes/formidable-async-patch.php';
function itstudio_recruitment_activate() {
flush_rewrite_rules();