insert([ 'sys' => $sys, 'type' => $type, 'level' => $level, 'title' => $title, 'body' => $body, 'url' => $url, 'ref_no' => $refNo, 'recipients' => json_encode($recipients, JSON_UNESCAPED_UNICODE), 'channels' => json_encode(array_values(array_unique($channels)), JSON_UNESCAPED_UNICODE), 'read_by' => json_encode([], JSON_UNESCAPED_UNICODE), 'created_at' => date('Y-m-d H:i:s'), ]); } catch (\Throwable $e) { error_log('[Notify] fire failed: ' . $e->getMessage()); } } /* ============== 业务便捷方法 ============== */ /** 新销售订单 */ public static function newSalesOrder(string $orderNo, string $customer, string $salesman, int $id): void { $title = "【紧急】新销售订单待跟进:{$orderNo}"; $body = "客户:{$customer}\n负责人:{$salesman}\n订单号:{$orderNo}\n请尽快处理并安排发货。"; self::fire('sales_order', $title, $body, [ 'level' => 'urgent', 'ref_no' => $orderNo, 'url' => "PSI/sales_orders/show/{$id}", ]); } /** 新采购订单 */ public static function newPurchaseOrder(string $orderNo, string $supplier, string $buyer, int $id): void { $title = "【紧急】新采购订单待处理:{$orderNo}"; $body = "供应商:{$supplier}\n采购人:{$buyer}\n订单号:{$orderNo}\n请尽快审核并安排收货。"; self::fire('purchase_order', $title, $body, [ 'level' => 'urgent', 'ref_no' => $orderNo, 'url' => "PSI/purchase_orders/show/{$id}", ]); } /** 新客户订单(来自前台网站下单) */ public static function newCustomerOrder(string $orderNo, string $customer, string $phone, int $id): void { $title = "【紧急】收到新客户订单:{$orderNo}"; $body = "客户:{$customer}\n电话:{$phone}\n订单号:{$orderNo}\n请尽快联系客户并安排发货。"; self::fire('customer_order', $title, $body, [ 'level' => 'urgent', 'ref_no' => $orderNo, 'url' => "PSI/orders/show/{$id}", ]); } /** 低库存预警(库存跌破阈值时触发) */ public static function lowStockEvent(string $itemType, string $name, float $stock, float $threshold, int $itemId): void { $kind = $itemType === 'product' ? '成品' : '物料'; $title = "【紧急】{$kind}库存不足:{$name}"; $body = "{$kind}:{$name}\n当前库存:{$stock}\n预警阈值:{$threshold}\n请及时补货。"; self::fire('low_stock', $title, $body, [ 'level' => 'urgent', 'ref_no' => $name, 'url' => "PSI/stock", ]); } /* ============== 收件人与开关 ============== */ private static function recipients(): array { $s = new Setting(); $emailTo = trim((string) $s->get('notify_email_to', ''), " \t\n\r,"); $wechat = trim((string) $s->get('notify_wechat_mention', ''), " \t\n\r,"); return [ 'email' => $emailTo === '' ? [] : array_filter(array_map('trim', explode(',', $emailTo))), 'wechat' => $wechat === '' ? [] : array_filter(array_map('trim', explode(',', $wechat))), ]; } private static function masterEnabled(): bool { return (int) (new Setting())->get('notify_enabled', 0) === 1; } private static function emailEnabled(): bool { if (!self::masterEnabled()) return false; return (int) (new Setting())->get('notify_email_enabled', 0) === 1; } private static function wechatEnabled(): bool { if (!self::masterEnabled()) return false; return (int) (new Setting())->get('notify_wechat_enabled', 0) === 1 && trim((string) (new Setting())->get('notify_wechat_webhook', '')) !== ''; } public static function lowStockEnabled(): bool { return (int) (new Setting())->get('notify_lowstock_enabled', 1) === 1; } public static function lowStockThreshold(): float { return (float) (new Setting())->get('notify_lowstock_threshold', 20); } /* ============== 邮件发送 ============== */ private static function sendEmail(array $to, string $subject, string $body, string $url, string $level): bool { $s = new Setting(); $host = trim((string) $s->get('notify_email_smtp_host', '')); $port = (int) $s->get('notify_email_smtp_port', 465); $user = trim((string) $s->get('notify_email_smtp_user', '')); $pass = trim((string) $s->get('notify_email_smtp_pass', '')); $from = trim((string) $s->get('notify_email_from', '')); if ($from === '') $from = $user; $html = self::emailHtml($subject, $body, $url, $level); if ($host !== '' && $user !== '') { $scheme = ($port === 465) ? 'ssl' : 'tls'; return self::smtpSend($host, $port, $scheme, $user, $pass, $from, $to, $subject, $html); } // 回退:PHP 内置 mail() $headers = "MIME-Version: 1.0\r\n"; $headers .= "Content-Type: text/html; charset=UTF-8\r\n"; $headers .= "From: {$from}\r\n"; $ok = true; foreach ($to as $t) { if (!@mail($t, '=?UTF-8?B?' . base64_encode($subject) . '?=', $html, $headers)) $ok = false; } return $ok; } private static function emailHtml(string $subject, string $body, string $url, string $level): string { $lines = nl2br(htmlspecialchars($body, ENT_QUOTES, 'UTF-8')); $link = $url ? App::url($url) : ''; $urgent = $level === 'urgent' ? '紧急事件' : '通知'; return <<
HTML; } private static function smtpSend(string $host, int $port, string $scheme, string $user, string $pass, string $from, array $to, string $subject, string $html): bool { $timeout = 15; $ctx = $scheme === 'ssl' ? stream_context_create(['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]]) : null; $prefix = $scheme === 'ssl' ? 'ssl://' : ''; $fp = @stream_socket_client($prefix . $host . ':' . $port, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx); if (!$fp) return false; $talk = function ($cmd = null) use ($fp) { if ($cmd !== null) fwrite($fp, $cmd . "\r\n"); $res = ''; while (($line = fgets($fp, 600)) !== false) { $res .= $line; if (isset($line[3]) && $line[3] === ' ') break; // 单行响应(响应码后的第4个字符是空格) if ($line === '') break; } return $res; }; $talk(null); // 欢迎语 $talk('EHLO ' . (gethostname() ?: 'localhost')); if ($scheme === 'tls' || $port === 587 || $port === 25) { $r = $talk('STARTTLS'); if (strpos($r, '220') === 0) { if (!@stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { fclose($fp); return false; } $talk('EHLO ' . (gethostname() ?: 'localhost')); } } if ($user !== '') { $talk('AUTH LOGIN'); $talk(base64_encode($user)); $talk(base64_encode($pass)); } $talk('MAIL FROM:<' . $from . '>'); foreach ($to as $t) $talk('RCPT TO:<' . $t . '>'); $talk('DATA'); $headers = "From: {$from}\r\n"; $headers .= "To: " . implode(', ', $to) . "\r\n"; $headers .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n"; $headers .= "MIME-Version: 1.0\r\n"; $headers .= "Content-Type: text/html; charset=UTF-8\r\n"; $talk($headers . "\r\n" . $html . "\r\n."); $talk('QUIT'); fclose($fp); return true; } /* ============== 企业微信(群机器人 webhook) ============== */ private static function sendWeChat(string $title, string $body, string $url, array $mention): bool { $webhook = trim((string) (new Setting())->get('notify_wechat_webhook', '')); if ($webhook === '') return false; $content = "**{$title}**\n> " . str_replace("\n", "\n> ", $body); if ($url) $content .= "\n\n[查看详情](" . App::url($url) . ")"; $payload = ['msgtype' => 'markdown', 'markdown' => ['content' => $content]]; if ($mention) $payload['markdown']['mentioned_mobile_list'] = array_values($mention); return self::httpPostJson($webhook, $payload); } private static function httpPostJson(string $url, array $payload): bool { $json = json_encode($payload, JSON_UNESCAPED_UNICODE); if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'], CURLOPT_POSTFIELDS => $json, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, ]); $res = curl_exec($ch); curl_close($ch); return $res !== false; } $ctx = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => "Content-Type: application/json; charset=utf-8\r\n", 'content' => $json, 'timeout' => 10, ], ]); $res = @file_get_contents($url, false, $ctx); return $res !== false; } /* ============== 事件表(按需创建,兼容 MySQL / json 两种存储) ============== */ private static function ensureTable(): void { if (Db::driver() !== 'mysql') return; // json 模式由 Model 自动建文件 $sql = "CREATE TABLE IF NOT EXISTS `psi_events` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `sys` VARCHAR(20) NOT NULL DEFAULT 'psi', `type` VARCHAR(40) NOT NULL DEFAULT '', `level` VARCHAR(20) NOT NULL DEFAULT 'urgent', `title` VARCHAR(255) NOT NULL DEFAULT '', `body` TEXT, `url` VARCHAR(255) NOT NULL DEFAULT '', `ref_no` VARCHAR(64) NOT NULL DEFAULT '', `recipients` TEXT, `channels` VARCHAR(255) NOT NULL DEFAULT '[\"inapp\"]', `read_by` TEXT, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"; Db::query($sql); } }