文件还在测试中
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user