Files
coolcoth.com/app/Core/Notify.php
T
2026-08-08 15:53:53 +08:00

327 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Core;
use App\Models\PSI\Event;
use App\Models\Setting;
/**
* PSI / 站点事件通知服务
* --------------------------------------------------
* 当 PSI 内出现「新订单 / 新事件」时统一调用本服务:
* 1) 记录一条“紧急事件”到 psi_events(站内提醒中心,所有相关人员可见)
* 2) (可选)邮件通知负责人(SMTP / mail()
* 3) (可选)企业微信群机器人(webhook)通知负责人并 @ 提醒
*
* 邮件/微信是否发送取决于“通知设置”(Settings):
* notify_enabled 总开关
* notify_email_enabled 邮件开关
* notify_email_smtp_host 发信服务器(留空则用 mail())
* notify_email_smtp_port 端口(465=SSL / 587=STARTTLS
* notify_email_smtp_user 账号
* notify_email_smtp_pass 密码
* notify_email_from 发件人
* notify_email_to 负责人邮箱(逗号分隔)
* notify_wechat_enabled 企业微信开关
* notify_wechat_webhook 企业微信机器人 webhook 地址
* notify_wechat_mention 被@的手机号/英文id(逗号分隔)
* notify_lowstock_enabled 低库存提醒开关
* notify_lowstock_threshold 低库存阈值
*/
class Notify
{
/** 触发一个事件(默认紧急)。自动记录站内提醒并推送渠道。 */
public static function fire(string $type, string $title, string $body, array $opts = []): void
{
try {
self::ensureTable();
$level = $opts['level'] ?? 'urgent';
$url = $opts['url'] ?? '';
$refNo = $opts['ref_no'] ?? '';
$sys = $opts['sys'] ?? 'psi';
$recipients = self::recipients();
$channels = ['inapp'];
// ① 邮件
if (self::emailEnabled() && $recipients['email']) {
if (self::sendEmail($recipients['email'], $title, $body, $url, $level)) {
$channels[] = 'email';
}
}
// ② 企业微信
if (self::wechatEnabled()) {
if (self::sendWeChat($title, $body, $url, $recipients['wechat'])) {
$channels[] = 'wechat';
}
}
// ③ 站内紧急事件(始终记录,即使渠道未配置)
(new Event())->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' ? '<span style="color:#dc2626;font-weight:700;">紧急事件</span>' : '通知';
return <<<HTML
<!doctype html><html lang="zh-CN"><body style="margin:0;background:#f3f4f6;font-family:-apple-system,'Segoe UI',Roboto,'PingFang SC','Microsoft YaHei',sans-serif;">
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 8px 30px rgba(15,42,68,.12);">
<div style="background:linear-gradient(135deg,#0ea5e9,#14b8a6);padding:18px 22px;color:#fff;font-size:16px;font-weight:700;">酷冰甲 · PSI 进销存 {$urgent}</div>
<div style="padding:22px;color:#0f2a44;font-size:15px;line-height:1.8;">
<div style="font-size:16px;font-weight:700;margin-bottom:10px;">{$subject}</div>
<div style="color:#334155;">{$lines}</div>
{$link}
</div>
<div style="padding:0 22px 20px;">
<a href="{$link}" style="display:inline-block;padding:10px 18px;border-radius:10px;background:linear-gradient(135deg,#0ea5e9,#14b8a6);color:#fff;text-decoration:none;font-weight:600;">查看详情</a>
</div>
<div style="padding:14px 22px;background:#f8fafc;color:#94a3b8;font-size:12px;border-top:1px solid #eef2f7;">本邮件由系统自动发出,请勿直接回复。</div>
</div></body></html>
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);
}
}