Merge branch 'develop'
# Conflicts: # app/controller/IndexController.php
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace app\bootstrap;
|
||||
|
||||
use Webman\Bootstrap;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use app\common\audit\AuditBuffer;
|
||||
|
||||
class Audit implements Bootstrap
|
||||
{
|
||||
public static function start($worker)
|
||||
{
|
||||
Model::created(function ($model) {
|
||||
AuditBuffer::instance()->pushModelChange($model->getTable(), $model->getKey(), [], $model->getAttributes());
|
||||
});
|
||||
Model::updated(function ($model) {
|
||||
$dirty = $model->getDirty();
|
||||
if (!$dirty) return;
|
||||
$old = []; $new = [];
|
||||
foreach ($dirty as $k=>$v) {
|
||||
$old[$k] = $model->getOriginal($k);
|
||||
$new[$k] = $model->getAttribute($k);
|
||||
}
|
||||
AuditBuffer::instance()->pushModelChange($model->getTable(), $model->getKey(), $old, $new);
|
||||
});
|
||||
Model::deleted(function ($model) {
|
||||
AuditBuffer::instance()->pushModelChange($model->getTable(), $model->getKey(), $model->getAttributes(), []);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
namespace app\common\audit;
|
||||
|
||||
use Webman\Context;
|
||||
|
||||
class AuditBuffer
|
||||
{
|
||||
const CTX_KEY = '__audit_buffer__';
|
||||
|
||||
public array $changes = []; // 每项:['scene','object_id','field','old','new']
|
||||
public ?string $action = null; // 业务动作(可覆盖)
|
||||
public array $meta = []; // 业务自定义元信息(如需扩展入库,可在消费者处处理)
|
||||
public array $maskFields = ['password','pwd','token','secret','access_token','refresh_token'];
|
||||
|
||||
public static function instance(): self
|
||||
{
|
||||
$buf = Context::get(self::CTX_KEY);
|
||||
if (!$buf) {
|
||||
$buf = new self();
|
||||
Context::set(self::CTX_KEY, $buf);
|
||||
}
|
||||
return $buf;
|
||||
}
|
||||
|
||||
public static function reset(): void
|
||||
{
|
||||
Context::set(self::CTX_KEY, new self());
|
||||
}
|
||||
|
||||
public function setAction(?string $action): void
|
||||
{
|
||||
$this->action = $action;
|
||||
}
|
||||
|
||||
public function setMeta(array $meta): void
|
||||
{
|
||||
$this->meta = $meta + $this->meta;
|
||||
}
|
||||
|
||||
/** 模型字段级变更推送(由 Trait 调用) */
|
||||
public function pushModelChange(string $scene, $objectId, array $old, array $new): void
|
||||
{
|
||||
$keys = array_unique(array_merge(array_keys($old), array_keys($new)));
|
||||
foreach ($keys as $k) {
|
||||
$ov = $old[$k] ?? null;
|
||||
$nv = $new[$k] ?? null;
|
||||
if ($ov === $nv) continue;
|
||||
|
||||
[$ovMasked, $nvMasked] = $this->maskIfNeeded($k, $ov, $nv);
|
||||
$this->changes[] = [
|
||||
'scene' => $scene,
|
||||
'object_id' => $objectId,
|
||||
'field' => $k,
|
||||
'old' => $ovMasked,
|
||||
'new' => $nvMasked,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function maskIfNeeded(string $field, $old, $new): array
|
||||
{
|
||||
foreach ($this->maskFields as $m) {
|
||||
if (stripos($field, $m) !== false) {
|
||||
return [$old===null?null:'***', $new===null?null:'***'];
|
||||
}
|
||||
}
|
||||
if (preg_match('/(mobile|phone|tel|idcard|id_card)/i', $field)) {
|
||||
$mask = function ($v) {
|
||||
if ($v === null) return null;
|
||||
$s = (string)$v;
|
||||
return mb_substr($s, 0, 3).'****'.mb_substr($s, -4);
|
||||
};
|
||||
return [$mask($old), $mask($new)];
|
||||
}
|
||||
return [$old, $new];
|
||||
}
|
||||
}
|
||||
@@ -425,6 +425,7 @@ class AuthLogic
|
||||
'exp' => $exp,
|
||||
'typ' => 'access',
|
||||
'uid' => $user->id,
|
||||
'uname' => $user->name,
|
||||
'role_ids' => $role_ids,
|
||||
'system_ids' => $system_ids
|
||||
];
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
namespace app\common\traits;
|
||||
|
||||
use app\common\audit\AuditBuffer;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
trait Auditable
|
||||
{
|
||||
// 可在模型中设置:
|
||||
// public $auditDisabled = true; // 关闭该模型的日志
|
||||
// public $auditScene = 'alias'; // 覆盖默认场景(默认=表名)
|
||||
|
||||
public static function bootAuditable()
|
||||
{
|
||||
static::created(function (Model $m) {
|
||||
if (self::isMuted($m)) return;
|
||||
AuditBuffer::instance()->pushModelChange(self::scene($m), $m->getKey(), [], $m->getAttributes());
|
||||
});
|
||||
|
||||
static::updated(function (Model $m) {
|
||||
if (self::isMuted($m)) return;
|
||||
$dirty = $m->getDirty();
|
||||
if (!$dirty) return;
|
||||
$old = []; $new = [];
|
||||
foreach (array_keys($dirty) as $k) {
|
||||
$old[$k] = $m->getOriginal($k);
|
||||
$new[$k] = $m->getAttribute($k);
|
||||
}
|
||||
AuditBuffer::instance()->pushModelChange(self::scene($m), $m->getKey(), $old, $new);
|
||||
});
|
||||
|
||||
static::deleted(function (Model $m) {
|
||||
if (self::isMuted($m)) return;
|
||||
AuditBuffer::instance()->pushModelChange(self::scene($m), $m->getKey(), $m->getAttributes(), []);
|
||||
});
|
||||
}
|
||||
|
||||
protected static function scene(Model $m): string
|
||||
{
|
||||
return property_exists($m, 'auditScene') && $m->auditScene ? $m->auditScene : $m->getTable();
|
||||
}
|
||||
|
||||
protected static function isMuted(Model $m): bool
|
||||
{
|
||||
if (property_exists($m, 'auditDisabled') && $m->auditDisabled) return true;
|
||||
return $m->getTable() === 'sys_op_log'; // 避免自记录
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use support\Request;
|
||||
use support\Db;
|
||||
use support\Cache;
|
||||
use support\Redis;
|
||||
use GuzzleHttp\Client;
|
||||
|
||||
class IndexController
|
||||
{
|
||||
@@ -101,11 +102,46 @@ class IndexController
|
||||
|
||||
public function test(Request $request)
|
||||
{
|
||||
$result = $this->tocurl('https://magicbridgeauto.com');
|
||||
if (preg_match('/magicbridgeauto/', $result['body'])) {
|
||||
echo "未被篡改";
|
||||
flg("【magicbridgeauto】未被篡改");
|
||||
} else {
|
||||
echo "已被篡改";
|
||||
flg("【magicbridgeauto】已被篡改");
|
||||
}
|
||||
}
|
||||
|
||||
$info = \app\common\model\PurchaseOrders::find(14);
|
||||
$service = new \app\common\service\PurchaseOrdersService();
|
||||
$result = $service->startApproval($info);
|
||||
|
||||
return json($result);
|
||||
public function tocurl($url, $header = null, $content = null)
|
||||
{
|
||||
$ch = curl_init();
|
||||
if(substr($url,0,5)=='https'){
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 跳过证书检查
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); // 从证书中检查SSL加密算法是否存在
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, 1);
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
if (is_array($header)) {
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
if (!empty($content)) {
|
||||
if (is_array($content)) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($content));
|
||||
} else if (is_string($content)) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $content);
|
||||
}
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$error=curl_error($ch);
|
||||
if($error){
|
||||
die($error);
|
||||
}
|
||||
$header = curl_getinfo($ch);
|
||||
curl_close($ch);
|
||||
$data = array('header' => $header,'body' => $response);
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
namespace app\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
use app\common\audit\AuditBuffer;
|
||||
use app\common\model\User;
|
||||
use Webman\RedisQueue\Redis as Queue;
|
||||
use support\Log;
|
||||
|
||||
class Audit implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$start = microtime(true);
|
||||
AuditBuffer::reset();
|
||||
|
||||
$httpCode = 200; $message = null; $status = 1;
|
||||
|
||||
try {
|
||||
$response = $handler($request);
|
||||
$httpCode = $response->getStatusCode();
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
$httpCode = $e->getCode() ?: 500;
|
||||
$message = mb_substr($e->getMessage(), 0, 180);
|
||||
$status = 0;
|
||||
throw $e;
|
||||
} finally {
|
||||
$latency = (int)((microtime(true) - $start) * 1000);
|
||||
$this->enqueue($request, $httpCode, $status, $message, $latency);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected function enqueue(Request $req, int $httpCode, int $status, ?string $message, int $latencyMs): void
|
||||
{
|
||||
$buf = AuditBuffer::instance();
|
||||
|
||||
$user = $this->currentUser($req->user);
|
||||
$paramsBrief = $this->briefParams($req->all());
|
||||
$ua = (string)($req->header('user-agent') ?? '');
|
||||
if (mb_strlen($ua) > 191) $ua = mb_substr($ua, 0, 191);
|
||||
|
||||
$payload = [
|
||||
'user_id' => $user['id'] ?? null,
|
||||
'user_name' => $user['name'] ?? null,
|
||||
'route' => $req->path(),
|
||||
'method' => $req->method(),
|
||||
'ip' => $req->getRealIp(),
|
||||
'ua' => $ua,
|
||||
'action' => $buf->action ?? $req->action,
|
||||
'status' => $status,
|
||||
'http_code' => $httpCode,
|
||||
'latency_ms' => $latencyMs,
|
||||
'params_brief' => $paramsBrief, // 数组,消费者再编码
|
||||
'changes' => $buf->changes, // 数组,消费者再编码
|
||||
'message' => $message,
|
||||
'ts' => time()
|
||||
];
|
||||
|
||||
try {
|
||||
Queue::send('audit_log', $payload); // 异步
|
||||
} catch (\Throwable $e) {
|
||||
// 遵循“绝不回落同步写”,仅记录错误
|
||||
Log::error('audit enqueue failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected function briefParams(array $params): array
|
||||
{
|
||||
// $mask = ['password','pwd','token','secret','access_token','refresh_token','file','content','html'];
|
||||
$mask = [];
|
||||
$out = [];
|
||||
foreach ($params as $k=>$v) {
|
||||
if (in_array($k, $mask, true)) { $out[$k] = '***'; continue; }
|
||||
if (is_array($v)) { $out[$k] = '[array('.count($v).')]'; continue; }
|
||||
$s = (string)$v;
|
||||
$out[$k] = mb_strlen($s) > 120 ? mb_substr($s, 0, 120).'…' : $s;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
protected function inferAction(Request $req): ?string
|
||||
{
|
||||
$m = strtoupper($req->method());
|
||||
if ($m === 'POST') return 'create';
|
||||
if ($m === 'PUT' || $m === 'PATCH') return 'update';
|
||||
if ($m === 'DELETE') return 'delete';
|
||||
return 'view';
|
||||
}
|
||||
|
||||
protected function currentUser($user): array
|
||||
{
|
||||
$user = (array)$user;
|
||||
|
||||
if (!$user) {
|
||||
return [
|
||||
'id' => 0,
|
||||
'name' => 'guest'
|
||||
];
|
||||
} else {
|
||||
if (!isset($user['uname'])) {
|
||||
$user['uname'] = User::where('id', $user['uid'])->value('name');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $user['uid'] ?? 0,
|
||||
'name' => $user['uname'] ?? 'guest'
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use Workerman\Crontab\Crontab;
|
||||
|
||||
class CheckWeb
|
||||
{
|
||||
|
||||
public function onWorkerStart()
|
||||
{
|
||||
if (getenv('ENVIRONMENT') === 'master') {
|
||||
new Crontab('0 */10 * * * *', function(){
|
||||
$this->check();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function check()
|
||||
{
|
||||
$result = $this->tocurl('https://magicbridgeauto.com');
|
||||
if (preg_match('/magicbridgeauto/', $result['body'])) {
|
||||
flg("【magicbridgeauto】未检查到异常");
|
||||
} else {
|
||||
flg("【magicbridgeauto】疑似被篡改");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function tocurl($url, $header = null, $content = null)
|
||||
{
|
||||
$ch = curl_init();
|
||||
if(substr($url,0,5)=='https'){
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 跳过证书检查
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); // 从证书中检查SSL加密算法是否存在
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, 1);
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
if (is_array($header)) {
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
if (!empty($content)) {
|
||||
if (is_array($content)) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($content));
|
||||
} else if (is_string($content)) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $content);
|
||||
}
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$error=curl_error($ch);
|
||||
if($error){
|
||||
die($error);
|
||||
}
|
||||
$header = curl_getinfo($ch);
|
||||
curl_close($ch);
|
||||
$data = array('header' => $header,'body' => $response);
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace app\queue\redis;
|
||||
|
||||
use support\Db;
|
||||
use support\Log;
|
||||
use Webman\RedisQueue\Consumer;
|
||||
|
||||
class AuditLog implements Consumer
|
||||
{
|
||||
// 要消费的队列名
|
||||
public $queue = 'audit_log';
|
||||
|
||||
// 连接名,对应 plugin/webman/redis-queue/redis.php 里的连接`
|
||||
public $connection = 'default';
|
||||
|
||||
// 消费
|
||||
public function consume($data)
|
||||
{
|
||||
try {
|
||||
$now = (int)($data['ts'] ?? time());
|
||||
|
||||
$row = [
|
||||
// 'trace_id' => $data['trace_id'] ?? null,
|
||||
'user_id' => $data['user_id'] ?? null,
|
||||
'user_name' => $data['user_name'] ?? null,
|
||||
'route' => $data['route'] ?? null,
|
||||
'method' => $data['method'] ?? null,
|
||||
'ip' => $data['ip'] ?? null,
|
||||
'ua' => $data['ua'] ?? null,
|
||||
'action' => $data['action'] ?? null,
|
||||
'status' => (int)($data['status'] ?? 1),
|
||||
'http_code' => (int)($data['http_code'] ?? 200),
|
||||
'latency_ms' => $data['latency_ms'] ?? null,
|
||||
'params_brief' => !empty($data['params_brief']) ? json_encode($data['params_brief'], JSON_UNESCAPED_UNICODE) : null,
|
||||
'changes' => !empty($data['changes']) ? json_encode($data['changes'], JSON_UNESCAPED_UNICODE) : null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
Db::table('sys_op_log')->insert($row);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('audit_log consume error: '.$e->getMessage());
|
||||
return false; // 允许重试与失败处理
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 消费失败回调
|
||||
/*
|
||||
$package = [
|
||||
'id' => 1357277951, // 消息ID
|
||||
'time' => 1709170510, // 消息时间
|
||||
'delay' => 0, // 延迟时间
|
||||
'attempts' => 2, // 消费次数
|
||||
'queue' => 'send-mail', // 队列名
|
||||
'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容
|
||||
'max_attempts' => 5, // 最大重试次数
|
||||
'error' => '错误信息' // 错误信息
|
||||
]
|
||||
*/
|
||||
public function onConsumeFailure(\Throwable $e, $package)
|
||||
{
|
||||
echo "consume failure\n";
|
||||
echo $e->getMessage() . "\n";
|
||||
// 无需反序列化
|
||||
var_export($package);
|
||||
|
||||
Log::error('audit_log message failed after retries: '.json_encode($package, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,7 @@
|
||||
|
||||
return [
|
||||
support\bootstrap\Session::class,
|
||||
|
||||
// 日志
|
||||
app\bootstrap\Audit::class,
|
||||
];
|
||||
|
||||
@@ -17,6 +17,7 @@ return [
|
||||
'' => [
|
||||
\app\middleware\AccessControl::class,
|
||||
\app\middleware\BasicData::class,
|
||||
\app\middleware\AuthCheck::class
|
||||
\app\middleware\AuthCheck::class,
|
||||
\app\middleware\Audit::class,
|
||||
]
|
||||
];
|
||||
Reference in New Issue
Block a user