Feedback.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. <?php
  2. namespace app\index\controller;
  3. use app\common\controller\Frontend;
  4. use app\common\model\Feedback as FeedbackModel;
  5. use app\common\model\MessageFeedback;
  6. use think\captcha\facade\Captcha;
  7. use think\facade\Cache;
  8. use think\facade\Db;
  9. use think\facade\Validate;
  10. class Feedback extends Frontend
  11. {
  12. protected $noNeedLogin = ['submit', 'message', 'captcha'];
  13. protected $noNeedRight = ['*'];
  14. protected function frontendCurrentUrl(): string
  15. {
  16. $url = (string) $this->request->url(true);
  17. return function_exists('cms_lang_url') ? cms_lang_url($url) : $url;
  18. }
  19. protected function normalizeLang(string $lang): string
  20. {
  21. $lang = strtolower(trim($lang));
  22. if ($lang === '' && function_exists('cms_current_region_lang')) {
  23. $lang = strtolower(trim((string) cms_current_region_lang('cn')));
  24. }
  25. return $lang !== '' ? $lang : 'cn';
  26. }
  27. protected function getInput(string $name, int $maxLength = 255): string
  28. {
  29. $value = trim((string)$this->request->post($name . '/s', ''));
  30. $value = strip_tags($value);
  31. $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);
  32. return mb_substr($value, 0, $maxLength, 'UTF-8');
  33. }
  34. protected function validPhone(string $phone): bool
  35. {
  36. $length = mb_strlen($phone, 'UTF-8');
  37. if ($length < 6 || $length > 40) {
  38. return false;
  39. }
  40. $digits = 0;
  41. foreach (preg_split('//u', $phone, -1, PREG_SPLIT_NO_EMPTY) as $char) {
  42. if ($char >= '0' && $char <= '9') {
  43. $digits++;
  44. continue;
  45. }
  46. if (!in_array($char, ['+', '-', ' ', '(', ')'], true)) {
  47. return false;
  48. }
  49. }
  50. return $digits >= 6;
  51. }
  52. protected function validLooseText(string $value, int $maxLength): bool
  53. {
  54. if (mb_strlen($value, 'UTF-8') > $maxLength) {
  55. return false;
  56. }
  57. foreach (['<', '>', '`', '{', '}', '[', ']', '$', '\\'] as $char) {
  58. if (strpos($value, $char) !== false) {
  59. return false;
  60. }
  61. }
  62. return true;
  63. }
  64. protected function validMessageFormToken(string $token): bool
  65. {
  66. $expected = md5(session_id() . 'message_feedback');
  67. return $token !== '' && hash_equals($expected, $token);
  68. }
  69. protected function containsSpam(string $text): bool
  70. {
  71. $lower = mb_strtolower($text, 'UTF-8');
  72. $badWords = ['http://', 'https://', '<script', 'iframe', 'casino', '博彩', '赌博', '贷款', '发票'];
  73. foreach ($badWords as $word) {
  74. if (mb_strpos($lower, $word) !== false) {
  75. return true;
  76. }
  77. }
  78. return false;
  79. }
  80. protected function riskCacheKey(string $suffix): string
  81. {
  82. return 'message_feedback_risk_' . md5((string)$this->request->ip() . '_' . $suffix);
  83. }
  84. protected function requestCount(string $key, int $ttl): int
  85. {
  86. $count = (int)Cache::get($key, 0) + 1;
  87. Cache::set($key, $count, $ttl);
  88. return $count;
  89. }
  90. protected function messageRiskSnapshot(string $email = '', string $phone = '', bool $countRequest = false): array
  91. {
  92. $ip = (string)$this->request->ip();
  93. $now = time();
  94. $minuteAgo = $now - 60;
  95. $hourAgo = $now - 3600;
  96. $dayAgo = $now - 86400;
  97. $minuteRequests = $countRequest ? $this->requestCount($this->riskCacheKey('minute'), 60) : (int)Cache::get($this->riskCacheKey('minute'), 0);
  98. $dayRequests = $countRequest ? $this->requestCount($this->riskCacheKey('day'), 86400) : (int)Cache::get($this->riskCacheKey('day'), 0);
  99. $ipMinute = MessageFeedback::where('ip', $ip)->where('createtime', '>=', $minuteAgo)->count();
  100. $ipHour = MessageFeedback::where('ip', $ip)->where('createtime', '>=', $hourAgo)->count();
  101. $ipDay = MessageFeedback::where('ip', $ip)->where('createtime', '>=', $dayAgo)->count();
  102. $emailHour = $email !== '' ? MessageFeedback::where('email', $email)->where('createtime', '>=', $hourAgo)->count() : 0;
  103. $phoneHour = $phone !== '' ? MessageFeedback::where('phone', $phone)->where('createtime', '>=', $hourAgo)->count() : 0;
  104. return [
  105. 'need_captcha' => ($minuteRequests >= 3 || $dayRequests >= 20 || $ipMinute >= 1 || $ipHour >= 3 || $ipDay >= 10 || $emailHour >= 2 || $phoneHour >= 2) ? 1 : 0,
  106. ];
  107. }
  108. protected function messageNeedCaptcha(string $email = '', string $phone = '', bool $countRequest = false): bool
  109. {
  110. $risk = $this->messageRiskSnapshot($email, $phone, $countRequest);
  111. return !empty($risk['need_captcha']);
  112. }
  113. protected function checkMessageSubmitLimit(string $email, string $phone): bool
  114. {
  115. $ip = (string)$this->request->ip();
  116. $now = time();
  117. $minuteAgo = $now - 60;
  118. $hourAgo = $now - 3600;
  119. $dayAgo = $now - 86400;
  120. if (MessageFeedback::where('ip', $ip)->where('createtime', '>=', $minuteAgo)->count() >= 1) {
  121. return false;
  122. }
  123. if (MessageFeedback::where('ip', $ip)->where('createtime', '>=', $hourAgo)->count() >= 5) {
  124. return false;
  125. }
  126. if (MessageFeedback::where('ip', $ip)->where('createtime', '>=', $dayAgo)->count() >= 20) {
  127. return false;
  128. }
  129. if ($email !== '' && MessageFeedback::where('email', $email)->where('createtime', '>=', $hourAgo)->count() >= 3) {
  130. return false;
  131. }
  132. if ($phone !== '' && MessageFeedback::where('phone', $phone)->where('createtime', '>=', $hourAgo)->count() >= 3) {
  133. return false;
  134. }
  135. return true;
  136. }
  137. protected function checkMessageCaptcha(bool $isCn): void
  138. {
  139. $verify = trim((string)$this->request->post('verify/s', ''));
  140. if ($verify === '') {
  141. $this->error($isCn ? '请输入验证码' : 'Please enter the verification code', null, ['need_captcha' => 1]);
  142. }
  143. if (!Captcha::check($verify)) {
  144. $this->error($isCn ? '验证码错误,请重新输入' : 'Incorrect verification code, please try again', null, ['need_captcha' => 1]);
  145. }
  146. }
  147. public function captcha()
  148. {
  149. return Captcha::create();
  150. }
  151. public function submit()
  152. {
  153. if (!$this->request->isPost()) {
  154. $this->error('请求方式错误');
  155. }
  156. $type = trim((string) $this->request->post('feedback_type/s', 'website'));
  157. $rating = (int) $this->request->post('rating/d', 0);
  158. $sourcePage = trim((string) $this->request->post('source_page/s', $this->frontendCurrentUrl()));
  159. $lang = $this->normalizeLang((string) $this->request->post('lang/s', ''));
  160. $ip = (string) $this->request->ip();
  161. $userAgent = substr((string) $this->request->server('HTTP_USER_AGENT'), 0, 255);
  162. $now = time();
  163. $typeList = [
  164. 'website' => '官网体验',
  165. 'service' => '服务体验',
  166. 'product' => '产品体验',
  167. ];
  168. if (!isset($typeList[$type])) {
  169. $this->error('评价类型错误');
  170. }
  171. if ($rating < 1 || $rating > 5) {
  172. $this->error('请选择 1-5 星评分');
  173. }
  174. $exists = FeedbackModel::where('ip', $ip)
  175. ->where('feedback_type', $type)
  176. ->where('status', 1)
  177. ->find();
  178. if ($exists) {
  179. $this->error('请不要重复提交');
  180. }
  181. $ipLimitWindowSeconds = 60;
  182. $ipLimitWindowMax = 10;
  183. $ipLimitDailyMax = 50;
  184. $dayKey = date('Ymd', $now);
  185. $ipWindowKey = 'feedback_submit_ip_window_' . md5($ip);
  186. $ipDailyKey = 'feedback_submit_ip_daily_' . md5($ip . '_' . $dayKey);
  187. $ipWindowHits = (int) Cache::get($ipWindowKey, 0);
  188. if ($ipWindowHits >= $ipLimitWindowMax) {
  189. $this->error('提交过于频繁,请稍后再试');
  190. }
  191. $ipDailyHits = (int) Cache::get($ipDailyKey, 0);
  192. if ($ipDailyHits >= $ipLimitDailyMax) {
  193. $this->error('今日提交次数已达上限,请明天再试');
  194. }
  195. Cache::set($ipWindowKey, $ipWindowHits + 1, $ipLimitWindowSeconds);
  196. Cache::set($ipDailyKey, $ipDailyHits + 1, 86400);
  197. Db::startTrans();
  198. try {
  199. $row = new FeedbackModel();
  200. $row->save([
  201. 'feedback_type' => $type,
  202. 'feedback_type_text' => $typeList[$type],
  203. 'rating' => $rating,
  204. 'lang' => $lang,
  205. 'source_page' => $sourcePage,
  206. 'ip' => $ip,
  207. 'user_agent' => $userAgent,
  208. 'status' => 1,
  209. 'createtime' => $now,
  210. 'updatetime' => $now,
  211. ]);
  212. Db::commit();
  213. } catch (\Throwable $e) {
  214. Db::rollback();
  215. $message = $e->getMessage();
  216. if (stripos($message, 'uniq_ip_type') !== false || stripos($message, 'Duplicate entry') !== false) {
  217. $this->error('请不要重复提交');
  218. }
  219. $this->error('评价提交失败:' . $message);
  220. }
  221. $msg = in_array($lang, ['cn', 'zh', 'zh-cn'], true)
  222. ? '提交成功,感谢你的评价'
  223. : 'Submitted successfully. Thank you for your feedback.';
  224. $this->success($msg, null, [
  225. 'id' => $row->id,
  226. 'feedback_type' => $type,
  227. 'rating' => $rating,
  228. 'lang' => $lang,
  229. ]);
  230. }
  231. public function message()
  232. {
  233. if (!$this->request->isPost()) {
  234. $this->error('请求方式错误');
  235. }
  236. $type = $this->getInput('feedback_type', 32);
  237. $name = $this->getInput('name', 80);
  238. $phone = $this->getInput('phone', 40);
  239. $email = $this->getInput('email', 120);
  240. $content = $this->getInput('content', 500);
  241. $sourcePage = mb_substr(trim((string)$this->request->post('source_page/s', $this->frontendCurrentUrl())), 0, 255, 'UTF-8');
  242. $lang = $this->normalizeLang((string)$this->request->post('lang/s', ''));
  243. $privacyAgree = (int)$this->request->post('privacy_agree/d', 0);
  244. $formToken = trim((string)$this->request->post('form_token/s', ''));
  245. $honeypot = trim((string)$this->request->post('website/s', ''));
  246. $isCn = in_array($lang, ['cn', 'zh', 'zh-cn'], true);
  247. $typeList = (new MessageFeedback())->getTypeList();
  248. $needCaptcha = $this->messageNeedCaptcha($email, $phone, true);
  249. if ($needCaptcha) {
  250. $this->checkMessageCaptcha($isCn);
  251. }
  252. if (!isset($typeList[$type])) {
  253. $this->error($isCn ? '请选择反馈类型' : 'Please select a feedback type');
  254. }
  255. if ($name === '' || !$this->validLooseText($name, 80)) {
  256. $this->error($isCn ? '请填写正确的姓名' : 'Please enter your name');
  257. }
  258. if ($phone === '' || !$this->validPhone($phone)) {
  259. $this->error($isCn ? '请填写正确的电话' : 'Please enter a valid phone number');
  260. }
  261. if ($email === '' || !Validate::is($email, 'email')) {
  262. $this->error($isCn ? '电子邮箱格式不正确' : 'Please enter a valid email address');
  263. }
  264. if ($content === '' || mb_strlen($content, 'UTF-8') > 500) {
  265. $this->error($isCn ? '请填写500字以内的反馈内容' : 'Please enter feedback within 500 characters');
  266. }
  267. if ($privacyAgree !== 1) {
  268. $this->error($isCn ? '请先同意隐私政策' : 'Please agree to the privacy policy first');
  269. }
  270. if ($honeypot !== '') {
  271. $this->error($isCn ? '提交失败,请稍后再试' : 'Submission failed, please try again later');
  272. }
  273. if (!$this->validMessageFormToken($formToken)) {
  274. $this->error($isCn ? '页面已过期,请刷新后重试' : 'The page has expired, please refresh and try again');
  275. }
  276. if ($this->containsSpam($content . ' ' . $name)) {
  277. $this->error($isCn ? '提交内容未通过安全校验' : 'The submitted content did not pass security validation');
  278. }
  279. $hourAgo = time() - 3600;
  280. $duplicate = MessageFeedback::where(function ($query) use ($email, $phone) {
  281. $query->where('email', $email)->whereOr('phone', $phone);
  282. })->where('content', $content)->where('createtime', '>=', $hourAgo)->count() > 0;
  283. if ($duplicate) {
  284. $this->success($isCn ? '您已提交过相同反馈,请勿重复提交。' : 'You have already submitted the same feedback.', null, ['duplicate' => 1, 'lang' => $lang]);
  285. }
  286. if ($this->messageNeedCaptcha($email, $phone, false) && !$needCaptcha) {
  287. $this->error($isCn ? '当前提交较频繁,请先完成验证码' : 'Please complete the verification code first', null, ['need_captcha' => 1]);
  288. }
  289. if (!$this->checkMessageSubmitLimit($email, $phone)) {
  290. $this->error($isCn ? '提交过于频繁,请稍后再试' : 'Too many submissions, please try again later', null, ['need_captcha' => 1]);
  291. }
  292. $ip = (string)$this->request->ip();
  293. $userAgent = mb_substr((string)$this->request->server('HTTP_USER_AGENT'), 0, 255, 'UTF-8');
  294. $now = time();
  295. Db::startTrans();
  296. try {
  297. $row = new MessageFeedback();
  298. $row->save([
  299. 'feedback_type' => $type,
  300. 'feedback_type_text' => $typeList[$type],
  301. 'name' => $name,
  302. 'phone' => $phone,
  303. 'email' => $email,
  304. 'content' => $content,
  305. 'lang' => $lang,
  306. 'source_page' => $sourcePage,
  307. 'ip' => $ip,
  308. 'user_agent' => $userAgent,
  309. 'privacy_agree' => 1,
  310. 'privacy_agree_time' => $now,
  311. 'privacy_agree_ip' => $ip,
  312. 'status' => 0,
  313. 'admin_remark' => '',
  314. 'createtime' => $now,
  315. 'updatetime' => $now,
  316. ]);
  317. Db::commit();
  318. } catch (\Throwable $e) {
  319. Db::rollback();
  320. $this->error(($isCn ? '提交失败:' : 'Submission failed: ') . $e->getMessage());
  321. }
  322. $this->success($isCn ? '提交成功,感谢您的反馈' : 'Submitted successfully. Thank you for your feedback.', null, [
  323. 'id' => $row->id,
  324. 'lang' => $lang,
  325. ]);
  326. }
  327. }