Cooperation.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. <?php
  2. namespace app\index\controller;
  3. use app\common\controller\Frontend;
  4. use app\common\model\CooperationLead;
  5. use app\common\model\CooperationOption;
  6. use think\facade\Cache;
  7. use think\facade\Db;
  8. use think\facade\Validate;
  9. use think\captcha\facade\Captcha;
  10. class Cooperation extends Frontend
  11. {
  12. protected $noNeedLogin = ['submit', 'options', '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 validFormToken(string $token): bool
  65. {
  66. $expected = md5(session_id() . 'cooperation_lead');
  67. return $token !== '' && hash_equals($expected, $token);
  68. }
  69. protected function validOption(string $type, string $value, string $lang): bool
  70. {
  71. if ($value === '') {
  72. return false;
  73. }
  74. return CooperationOption::where('type', $type)
  75. ->where('value', $value)
  76. ->where('lang', 'in', [$lang, 'cn'])
  77. ->where('status', 1)
  78. ->count() > 0;
  79. }
  80. protected function isDuplicateLead(string $email, string $phone): bool
  81. {
  82. $dayAgo = time() - 86400;
  83. return CooperationLead::where(function ($query) use ($email, $phone) {
  84. $query->where('email', $email)->whereOr('phone', $phone);
  85. })->where('createtime', '>=', $dayAgo)->count() > 0;
  86. }
  87. protected function duplicateLeadMessage(bool $isCn): string
  88. {
  89. return $isCn ? '您已提交过信息,我们会尽快与您联系,请勿重复提交。' : 'You have already submitted your information. We will contact you soon. Please do not submit repeatedly.';
  90. }
  91. protected function checkSubmitLimit(string $email, string $phone): bool
  92. {
  93. $ip = (string)$this->request->ip();
  94. $now = time();
  95. $minuteAgo = $now - 60;
  96. $hourAgo = $now - 3600;
  97. $dayAgo = $now - 86400;
  98. if (CooperationLead::where('ip', $ip)->where('createtime', '>=', $minuteAgo)->count() >= 1) {
  99. return false;
  100. }
  101. if (CooperationLead::where('ip', $ip)->where('createtime', '>=', $hourAgo)->count() >= 5) {
  102. return false;
  103. }
  104. if (CooperationLead::where('ip', $ip)->where('createtime', '>=', $dayAgo)->count() >= 20) {
  105. return false;
  106. }
  107. if (CooperationLead::where('email', $email)->where('createtime', '>=', $hourAgo)->count() >= 3) {
  108. return false;
  109. }
  110. if (CooperationLead::where('phone', $phone)->where('createtime', '>=', $hourAgo)->count() >= 3) {
  111. return false;
  112. }
  113. return true;
  114. }
  115. protected function riskCacheKey(string $suffix): string
  116. {
  117. return 'cooperation_risk_' . md5((string)$this->request->ip() . '_' . $suffix);
  118. }
  119. protected function requestCount(string $key, int $ttl): int
  120. {
  121. $count = (int)Cache::get($key, 0) + 1;
  122. Cache::set($key, $count, $ttl);
  123. return $count;
  124. }
  125. protected function riskSnapshot(string $email = '', string $phone = '', bool $countRequest = false): array
  126. {
  127. $ip = (string)$this->request->ip();
  128. $now = time();
  129. $minuteAgo = $now - 60;
  130. $hourAgo = $now - 3600;
  131. $dayAgo = $now - 86400;
  132. $minuteRequests = $countRequest ? $this->requestCount($this->riskCacheKey('minute'), 60) : (int)Cache::get($this->riskCacheKey('minute'), 0);
  133. $dayRequests = $countRequest ? $this->requestCount($this->riskCacheKey('day'), 86400) : (int)Cache::get($this->riskCacheKey('day'), 0);
  134. $ipMinuteLeads = CooperationLead::where('ip', $ip)->where('createtime', '>=', $minuteAgo)->count();
  135. $ipHourLeads = CooperationLead::where('ip', $ip)->where('createtime', '>=', $hourAgo)->count();
  136. $ipDayLeads = CooperationLead::where('ip', $ip)->where('createtime', '>=', $dayAgo)->count();
  137. $emailHourLeads = $email !== '' ? CooperationLead::where('email', $email)->where('createtime', '>=', $hourAgo)->count() : 0;
  138. $phoneHourLeads = $phone !== '' ? CooperationLead::where('phone', $phone)->where('createtime', '>=', $hourAgo)->count() : 0;
  139. $needCaptcha = $minuteRequests >= 3
  140. || $dayRequests >= 20
  141. || $ipMinuteLeads >= 1
  142. || $ipHourLeads >= 3
  143. || $ipDayLeads >= 10
  144. || $emailHourLeads >= 2
  145. || $phoneHourLeads >= 2;
  146. return [
  147. 'need_captcha' => $needCaptcha ? 1 : 0,
  148. 'minute_requests' => $minuteRequests,
  149. 'day_requests' => $dayRequests,
  150. 'ip_minute_leads' => $ipMinuteLeads,
  151. 'ip_hour_leads' => $ipHourLeads,
  152. 'ip_day_leads' => $ipDayLeads,
  153. 'email_hour_leads' => $emailHourLeads,
  154. 'phone_hour_leads' => $phoneHourLeads,
  155. ];
  156. }
  157. protected function needCaptcha(string $email = '', string $phone = '', bool $countRequest = false): bool
  158. {
  159. $risk = $this->riskSnapshot($email, $phone, $countRequest);
  160. return !empty($risk['need_captcha']);
  161. }
  162. protected function checkCaptcha(bool $isCn): void
  163. {
  164. $verify = trim((string)$this->request->post('verify/s', ''));
  165. if ($verify === '') {
  166. $this->error($isCn ? '请输入验证码' : 'Please enter the verification code', null, ['need_captcha' => 1]);
  167. }
  168. if (!Captcha::check($verify)) {
  169. $this->error($isCn ? '验证码错误,请重新输入' : 'Incorrect verification code, please try again', null, ['need_captcha' => 1]);
  170. }
  171. }
  172. public function captcha()
  173. {
  174. return Captcha::create();
  175. }
  176. protected function containsSpam(string $text): bool
  177. {
  178. $lower = mb_strtolower($text, 'UTF-8');
  179. $badWords = ['http://', 'https://', '<script', 'iframe', 'casino', '博彩', '赌博', '贷款', '发票'];
  180. foreach ($badWords as $word) {
  181. if (mb_strpos($lower, $word) !== false) {
  182. return true;
  183. }
  184. }
  185. return mb_strpos($lower, 'http://') !== false || mb_strpos($lower, 'https://') !== false;
  186. }
  187. public function options()
  188. {
  189. $lang = $this->normalizeLang((string)$this->request->param('lang/s', ''));
  190. $this->success('ok', null, [
  191. 'lang' => $lang,
  192. 'countries' => CooperationOption::getOptions('country', $lang),
  193. 'country_regions' => CooperationOption::getOptions('country_region', $lang),
  194. 'country_tree' => CooperationOption::getCountryTree($lang),
  195. 'industries' => CooperationOption::getOptions('industry', $lang),
  196. 'need_captcha' => $this->needCaptcha() ? 1 : 0,
  197. ]);
  198. }
  199. public function submit()
  200. {
  201. if (!$this->request->isPost()) {
  202. $this->error('请求方式错误');
  203. }
  204. $name = $this->getInput('name', 80);
  205. $phone = $this->getInput('phone', 40);
  206. $email = $this->getInput('email', 120);
  207. $company = $this->getInput('company', 120);
  208. $country = $this->getInput('country', 100);
  209. $industry = $this->getInput('industry', 100);
  210. $content = $this->getInput('content', 300);
  211. $sourcePage = trim((string)$this->request->post('source_page/s', $this->frontendCurrentUrl()));
  212. $sourcePage = mb_substr($sourcePage, 0, 255, 'UTF-8');
  213. $lang = $this->normalizeLang((string)$this->request->post('lang/s', ''));
  214. $privacyAgree = (int)$this->request->post('privacy_agree/d', 0);
  215. $formToken = trim((string)$this->request->post('form_token/s', ''));
  216. $honeypot = trim((string)$this->request->post('website/s', ''));
  217. $isCn = in_array($lang, ['cn', 'zh', 'zh-cn'], true);
  218. $needCaptcha = $this->needCaptcha($email, $phone, true);
  219. if ($needCaptcha) {
  220. $this->checkCaptcha($isCn);
  221. }
  222. if ($name === '') {
  223. $this->error($isCn ? '请填写姓名' : 'Please enter your name');
  224. }
  225. if (!$this->validLooseText($name, 80) || !$this->validLooseText($company, 120)) {
  226. $this->error($isCn ? '输入内容包含不支持的字符' : 'Unsupported characters detected');
  227. }
  228. if ($phone === '' || !$this->validPhone($phone)) {
  229. $this->error($isCn ? '请填写正确的电话' : 'Please enter a valid phone number');
  230. }
  231. if ($email === '' || !Validate::is($email, 'email')) {
  232. $this->error($isCn ? '电子邮箱格式不正确' : 'Please enter a valid email address');
  233. }
  234. if ($company === '') {
  235. $this->error($isCn ? '请填写公司名称' : 'Please enter your company name');
  236. }
  237. if ($country === '') {
  238. $this->error($isCn ? '请选择国家/地区' : 'Please select a country/region');
  239. }
  240. if ($industry === '') {
  241. $this->error($isCn ? '请选择所在行业' : 'Please select your industry');
  242. }
  243. if ($content === '') {
  244. $this->error($isCn ? '请填写咨询内容' : 'Please enter your inquiry');
  245. }
  246. if ($privacyAgree !== 1) {
  247. $this->error($isCn ? '请先同意隐私政策' : 'Please agree to the privacy policy first');
  248. }
  249. if ($this->isDuplicateLead($email, $phone)) {
  250. $this->success($this->duplicateLeadMessage($isCn), null, [
  251. 'duplicate' => 1,
  252. 'lang' => $lang,
  253. ]);
  254. }
  255. if ($honeypot !== '') {
  256. $this->error($isCn ? '提交失败,请稍后再试' : 'Submission failed, please try again later');
  257. }
  258. if (!$this->validFormToken($formToken)) {
  259. $this->error($isCn ? '页面已过期,请刷新后重试' : 'The page has expired, please refresh and try again');
  260. }
  261. if (!$this->checkSubmitLimit($email, $phone)) {
  262. $this->error($isCn ? '提交过于频繁,请稍后再试' : 'Too many submissions, please try again later');
  263. }
  264. if (!$this->validOption('country', $country, $lang)) {
  265. $this->error($isCn ? '请选择有效的国家/地区' : 'Please choose a valid country/region');
  266. }
  267. if (!$this->validOption('industry', $industry, $lang)) {
  268. $this->error($isCn ? '请选择有效的行业' : 'Please choose a valid industry');
  269. }
  270. if (mb_strlen($content, 'UTF-8') > 300) {
  271. $this->error($isCn ? '咨询内容请控制在300字以内' : 'Please keep your message within 300 characters');
  272. }
  273. if ($this->containsSpam($content . ' ' . $name . ' ' . $company)) {
  274. $this->error($isCn ? '提交内容未通过安全校验' : 'The submitted content did not pass security validation');
  275. }
  276. $ip = (string)$this->request->ip();
  277. $userAgent = mb_substr((string)$this->request->server('HTTP_USER_AGENT'), 0, 255, 'UTF-8');
  278. $ipLimitWindowSeconds = 60;
  279. $ipLimitWindowMax = 10;
  280. $ipLimitDailyMax = 50;
  281. $now = time();
  282. $dayKey = date('Ymd', $now);
  283. $ipWindowKey = 'cooperation_submit_ip_window_' . md5($ip);
  284. $ipDailyKey = 'cooperation_submit_ip_daily_' . md5($ip . '_' . $dayKey);
  285. $ipWindowHits = (int)Cache::get($ipWindowKey, 0);
  286. if ($ipWindowHits >= $ipLimitWindowMax) {
  287. $this->error($isCn ? '提交过于频繁,请稍后再试' : 'Too many submissions. Please try again later.');
  288. }
  289. $ipDailyHits = (int)Cache::get($ipDailyKey, 0);
  290. if ($ipDailyHits >= $ipLimitDailyMax) {
  291. $this->error($isCn ? '今日提交次数已达上限,请明天再试' : 'Daily submission limit reached. Please try again tomorrow.');
  292. }
  293. Cache::set($ipWindowKey, $ipWindowHits + 1, $ipLimitWindowSeconds);
  294. Cache::set($ipDailyKey, $ipDailyHits + 1, 86400);
  295. Db::startTrans();
  296. try {
  297. $lead = new CooperationLead();
  298. $lead->save([
  299. 'name' => $name,
  300. 'phone' => $phone,
  301. 'email' => $email,
  302. 'company' => $company,
  303. 'country' => $country,
  304. 'industry' => $industry,
  305. 'content' => $content,
  306. 'lang' => $lang,
  307. 'source_page' => $sourcePage,
  308. 'ip' => $ip,
  309. 'user_agent' => $userAgent,
  310. 'privacy_agree' => 1,
  311. 'privacy_agree_time' => $now,
  312. 'privacy_agree_ip' => $ip,
  313. 'status' => 0,
  314. 'admin_remark' => '',
  315. 'createtime' => $now,
  316. 'updatetime' => $now,
  317. ]);
  318. Db::commit();
  319. } catch (\Throwable $e) {
  320. Db::rollback();
  321. $this->error(($isCn ? '提交失败:' : 'Submission failed: ') . $e->getMessage());
  322. }
  323. $this->success($isCn ? '提交成功,我们会尽快与您联系' : 'Submitted successfully. We will contact you soon.', null, [
  324. 'id' => $lead->id,
  325. 'lang' => $lang,
  326. ]);
  327. }
  328. }