// +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\wxchat\service; /** * 内容安全服务:敏感词过滤 + 风险评分 * 敏感词存放于 wxapp_wxchat_banwords,缺失时使用内置兜底词库 */ class SafetyService { private static $instance = null; /** @var array|null [['word'=>, 'level'=>, 'replace'=>], ...] */ private $words = null; public static function instance(): self { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; } /** * 加载敏感词(带缓存 + 兜底) */ private function loadWords(): array { if ($this->words !== null) { return $this->words; } $this->words = []; try { $rows = \think\facade\Db::name('wxchat_banwords') ->where('status', 1) ->order('level', 'desc') ->select(); foreach ($rows as $r) { $this->words[] = [ 'word' => $r['word'], 'level' => (int)$r['level'], 'replace' => $r['replace_char'] ?: '**', ]; } } catch (\Throwable $e) { // 表可能未创建,忽略,使用兜底词库 } // 兜底词库 foreach (['fuck', 'shit', '赌博', '色情', '诈骗', '代开发票'] as $w) { $this->words[] = ['word' => $w, 'level' => 1, 'replace' => '**']; } return $this->words; } /** * 过滤敏感词(level=1 替换为 **;level=2 命中则整段清空并返回命中标记) * @return string 过滤后的文本 */ public function filter(string $text): string { if ($text === '') { return $text; } foreach ($this->loadWords() as $w) { if ($w['level'] >= 2) { if (mb_stripos($text, $w['word']) !== false) { // 高危词:整段清空(拦截) return ''; } } else { $text = $this->mbStrReplace($w['word'], $w['replace'], $text); } } return $text; } /** * 内容安全检测:返回是否命中高危词 */ public function check(string $text): array { $hit = []; foreach ($this->loadWords() as $w) { if (mb_stripos($text, $w['word']) !== false) { $hit[] = $w['word']; if ($w['level'] >= 2) { return ['blocked' => true, 'hit' => $hit]; } } } return ['blocked' => false, 'hit' => $hit]; } /** * 多字节安全的字符串替换 */ private function mbStrReplace($search, $replace, $subject): string { if ($search === '') { return $subject; } $parts = mb_split(preg_quote($search), $subject); if ($parts === false) { return str_ireplace($search, $replace, $subject); } return implode($replace, $parts); } }