| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362 |
- <?php
- namespace app\index\controller;
- use app\common\controller\Frontend;
- use app\common\model\Feedback as FeedbackModel;
- use app\common\model\MessageFeedback;
- use think\captcha\facade\Captcha;
- use think\facade\Cache;
- use think\facade\Db;
- use think\facade\Validate;
- class Feedback extends Frontend
- {
- protected $noNeedLogin = ['submit', 'message', 'captcha'];
- protected $noNeedRight = ['*'];
- protected function frontendCurrentUrl(): string
- {
- $url = (string) $this->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://', '<script', 'iframe', 'casino', '博彩', '赌博', '贷款', '发票'];
- foreach ($badWords as $word) {
- if (mb_strpos($lower, $word) !== false) {
- return true;
- }
- }
- return false;
- }
- protected function riskCacheKey(string $suffix): string
- {
- return 'message_feedback_risk_' . md5((string)$this->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,
- ]);
- }
- }
|