文件还在测试中

This commit is contained in:
2026-08-08 15:53:53 +08:00
parent 5f1e7adb64
commit 8101177cb5
241 changed files with 18074 additions and 22 deletions
+326
View File
@@ -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);
}
}