request->url(true); return function_exists('cms_lang_url') ? cms_lang_url($url) : $url; } protected function normalizeLang(string $lang): string { $lang = strtolower(trim($lang)); if ($lang === '' && function_exists('cms_current_region_lang')) { $lang = strtolower(trim((string) cms_current_region_lang('cn'))); } return $lang !== '' ? $lang : 'cn'; } protected function getInput(string $name, int $maxLength = 255): string { $value = trim((string)$this->request->post($name . '/s', '')); $value = strip_tags($value); $value = str_replace(["\0", "\x01", "\x02", "\x03", "\x04", "\x05", "\x06", "\x07", "\x08", "\x0B", "\x0C", "\x0E", "\x0F", "\x10", "\x11", "\x12", "\x13", "\x14", "\x15", "\x16", "\x17", "\x18", "\x19", "\x1A", "\x1B", "\x1C", "\x1D", "\x1E", "\x1F", "\x7F"], '', $value); return mb_substr($value, 0, $maxLength, 'UTF-8'); } protected function validPhone(string $phone): bool { $length = mb_strlen($phone, 'UTF-8'); if ($length < 6 || $length > 40) { return false; } $digits = 0; foreach (preg_split('//u', $phone, -1, PREG_SPLIT_NO_EMPTY) as $char) { if ($char >= '0' && $char <= '9') { $digits++; continue; } if (!in_array($char, ['+', '-', ' ', '(', ')'], true)) { return false; } } return $digits >= 6; } protected function validLooseText(string $value, int $maxLength): bool { if (mb_strlen($value, 'UTF-8') > $maxLength) { return false; } foreach (['<', '>', '`', '{', '}', '[', ']', '$', '\\'] as $char) { if (strpos($value, $char) !== false) { return false; } } return true; } protected function validMessageFormToken(string $token): bool { $expected = md5(session_id() . 'message_feedback'); return $token !== '' && hash_equals($expected, $token); } protected function containsSpam(string $text): bool { $lower = mb_strtolower($text, 'UTF-8'); $badWords = ['http://', 'https://', 'request->ip() . '_' . $suffix); } protected function requestCount(string $key, int $ttl): int { $count = (int)Cache::get($key, 0) + 1; Cache::set($key, $count, $ttl); return $count; } protected function messageRiskSnapshot(string $email = '', string $phone = '', bool $countRequest = false): array { $ip = (string)$this->request->ip(); $now = time(); $minuteAgo = $now - 60; $hourAgo = $now - 3600; $dayAgo = $now - 86400; $minuteRequests = $countRequest ? $this->requestCount($this->riskCacheKey('minute'), 60) : (int)Cache::get($this->riskCacheKey('minute'), 0); $dayRequests = $countRequest ? $this->requestCount($this->riskCacheKey('day'), 86400) : (int)Cache::get($this->riskCacheKey('day'), 0); $ipMinute = MessageFeedback::where('ip', $ip)->where('createtime', '>=', $minuteAgo)->count(); $ipHour = MessageFeedback::where('ip', $ip)->where('createtime', '>=', $hourAgo)->count(); $ipDay = MessageFeedback::where('ip', $ip)->where('createtime', '>=', $dayAgo)->count(); $emailHour = $email !== '' ? MessageFeedback::where('email', $email)->where('createtime', '>=', $hourAgo)->count() : 0; $phoneHour = $phone !== '' ? MessageFeedback::where('phone', $phone)->where('createtime', '>=', $hourAgo)->count() : 0; return [ 'need_captcha' => ($minuteRequests >= 3 || $dayRequests >= 20 || $ipMinute >= 1 || $ipHour >= 3 || $ipDay >= 10 || $emailHour >= 2 || $phoneHour >= 2) ? 1 : 0, ]; } protected function messageNeedCaptcha(string $email = '', string $phone = '', bool $countRequest = false): bool { $risk = $this->messageRiskSnapshot($email, $phone, $countRequest); return !empty($risk['need_captcha']); } protected function checkMessageSubmitLimit(string $email, string $phone): bool { $ip = (string)$this->request->ip(); $now = time(); $minuteAgo = $now - 60; $hourAgo = $now - 3600; $dayAgo = $now - 86400; if (MessageFeedback::where('ip', $ip)->where('createtime', '>=', $minuteAgo)->count() >= 1) { return false; } if (MessageFeedback::where('ip', $ip)->where('createtime', '>=', $hourAgo)->count() >= 5) { return false; } if (MessageFeedback::where('ip', $ip)->where('createtime', '>=', $dayAgo)->count() >= 20) { return false; } if ($email !== '' && MessageFeedback::where('email', $email)->where('createtime', '>=', $hourAgo)->count() >= 3) { return false; } if ($phone !== '' && MessageFeedback::where('phone', $phone)->where('createtime', '>=', $hourAgo)->count() >= 3) { return false; } return true; } protected function checkMessageCaptcha(bool $isCn): void { $verify = trim((string)$this->request->post('verify/s', '')); if ($verify === '') { $this->error($isCn ? '请输入验证码' : 'Please enter the verification code', null, ['need_captcha' => 1]); } if (!Captcha::check($verify)) { $this->error($isCn ? '验证码错误,请重新输入' : 'Incorrect verification code, please try again', null, ['need_captcha' => 1]); } } public function captcha() { return Captcha::create(); } public function submit() { if (!$this->request->isPost()) { $this->error('请求方式错误'); } $type = trim((string) $this->request->post('feedback_type/s', 'website')); $rating = (int) $this->request->post('rating/d', 0); $sourcePage = trim((string) $this->request->post('source_page/s', $this->frontendCurrentUrl())); $lang = $this->normalizeLang((string) $this->request->post('lang/s', '')); $ip = (string) $this->request->ip(); $userAgent = substr((string) $this->request->server('HTTP_USER_AGENT'), 0, 255); $now = time(); $typeList = [ 'website' => '官网体验', 'service' => '服务体验', 'product' => '产品体验', ]; if (!isset($typeList[$type])) { $this->error('评价类型错误'); } if ($rating < 1 || $rating > 5) { $this->error('请选择 1-5 星评分'); } $exists = FeedbackModel::where('ip', $ip) ->where('feedback_type', $type) ->where('status', 1) ->find(); if ($exists) { $this->error('请不要重复提交'); } $ipLimitWindowSeconds = 60; $ipLimitWindowMax = 10; $ipLimitDailyMax = 50; $dayKey = date('Ymd', $now); $ipWindowKey = 'feedback_submit_ip_window_' . md5($ip); $ipDailyKey = 'feedback_submit_ip_daily_' . md5($ip . '_' . $dayKey); $ipWindowHits = (int) Cache::get($ipWindowKey, 0); if ($ipWindowHits >= $ipLimitWindowMax) { $this->error('提交过于频繁,请稍后再试'); } $ipDailyHits = (int) Cache::get($ipDailyKey, 0); if ($ipDailyHits >= $ipLimitDailyMax) { $this->error('今日提交次数已达上限,请明天再试'); } Cache::set($ipWindowKey, $ipWindowHits + 1, $ipLimitWindowSeconds); Cache::set($ipDailyKey, $ipDailyHits + 1, 86400); Db::startTrans(); try { $row = new FeedbackModel(); $row->save([ 'feedback_type' => $type, 'feedback_type_text' => $typeList[$type], 'rating' => $rating, 'lang' => $lang, 'source_page' => $sourcePage, 'ip' => $ip, 'user_agent' => $userAgent, 'status' => 1, 'createtime' => $now, 'updatetime' => $now, ]); Db::commit(); } catch (\Throwable $e) { Db::rollback(); $message = $e->getMessage(); if (stripos($message, 'uniq_ip_type') !== false || stripos($message, 'Duplicate entry') !== false) { $this->error('请不要重复提交'); } $this->error('评价提交失败:' . $message); } $msg = in_array($lang, ['cn', 'zh', 'zh-cn'], true) ? '提交成功,感谢你的评价' : 'Submitted successfully. Thank you for your feedback.'; $this->success($msg, null, [ 'id' => $row->id, 'feedback_type' => $type, 'rating' => $rating, 'lang' => $lang, ]); } public function message() { if (!$this->request->isPost()) { $this->error('请求方式错误'); } $type = $this->getInput('feedback_type', 32); $name = $this->getInput('name', 80); $phone = $this->getInput('phone', 40); $email = $this->getInput('email', 120); $content = $this->getInput('content', 500); $sourcePage = mb_substr(trim((string)$this->request->post('source_page/s', $this->frontendCurrentUrl())), 0, 255, 'UTF-8'); $lang = $this->normalizeLang((string)$this->request->post('lang/s', '')); $privacyAgree = (int)$this->request->post('privacy_agree/d', 0); $formToken = trim((string)$this->request->post('form_token/s', '')); $honeypot = trim((string)$this->request->post('website/s', '')); $isCn = in_array($lang, ['cn', 'zh', 'zh-cn'], true); $typeList = (new MessageFeedback())->getTypeList(); $needCaptcha = $this->messageNeedCaptcha($email, $phone, true); if ($needCaptcha) { $this->checkMessageCaptcha($isCn); } if (!isset($typeList[$type])) { $this->error($isCn ? '请选择反馈类型' : 'Please select a feedback type'); } if ($name === '' || !$this->validLooseText($name, 80)) { $this->error($isCn ? '请填写正确的姓名' : 'Please enter your name'); } if ($phone === '' || !$this->validPhone($phone)) { $this->error($isCn ? '请填写正确的电话' : 'Please enter a valid phone number'); } if ($email === '' || !Validate::is($email, 'email')) { $this->error($isCn ? '电子邮箱格式不正确' : 'Please enter a valid email address'); } if ($content === '' || mb_strlen($content, 'UTF-8') > 500) { $this->error($isCn ? '请填写500字以内的反馈内容' : 'Please enter feedback within 500 characters'); } if ($privacyAgree !== 1) { $this->error($isCn ? '请先同意隐私政策' : 'Please agree to the privacy policy first'); } if ($honeypot !== '') { $this->error($isCn ? '提交失败,请稍后再试' : 'Submission failed, please try again later'); } if (!$this->validMessageFormToken($formToken)) { $this->error($isCn ? '页面已过期,请刷新后重试' : 'The page has expired, please refresh and try again'); } if ($this->containsSpam($content . ' ' . $name)) { $this->error($isCn ? '提交内容未通过安全校验' : 'The submitted content did not pass security validation'); } $hourAgo = time() - 3600; $duplicate = MessageFeedback::where(function ($query) use ($email, $phone) { $query->where('email', $email)->whereOr('phone', $phone); })->where('content', $content)->where('createtime', '>=', $hourAgo)->count() > 0; if ($duplicate) { $this->success($isCn ? '您已提交过相同反馈,请勿重复提交。' : 'You have already submitted the same feedback.', null, ['duplicate' => 1, 'lang' => $lang]); } if ($this->messageNeedCaptcha($email, $phone, false) && !$needCaptcha) { $this->error($isCn ? '当前提交较频繁,请先完成验证码' : 'Please complete the verification code first', null, ['need_captcha' => 1]); } if (!$this->checkMessageSubmitLimit($email, $phone)) { $this->error($isCn ? '提交过于频繁,请稍后再试' : 'Too many submissions, please try again later', null, ['need_captcha' => 1]); } $ip = (string)$this->request->ip(); $userAgent = mb_substr((string)$this->request->server('HTTP_USER_AGENT'), 0, 255, 'UTF-8'); $now = time(); Db::startTrans(); try { $row = new MessageFeedback(); $row->save([ 'feedback_type' => $type, 'feedback_type_text' => $typeList[$type], 'name' => $name, 'phone' => $phone, 'email' => $email, 'content' => $content, 'lang' => $lang, 'source_page' => $sourcePage, 'ip' => $ip, 'user_agent' => $userAgent, 'privacy_agree' => 1, 'privacy_agree_time' => $now, 'privacy_agree_ip' => $ip, 'status' => 0, 'admin_remark' => '', 'createtime' => $now, 'updatetime' => $now, ]); Db::commit(); } catch (\Throwable $e) { Db::rollback(); $this->error(($isCn ? '提交失败:' : 'Submission failed: ') . $e->getMessage()); } $this->success($isCn ? '提交成功,感谢您的反馈' : 'Submitted successfully. Thank you for your feedback.', null, [ 'id' => $row->id, 'lang' => $lang, ]); } }