2026-04-17 05:38:11 +07:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
use App\Libraries\ValueSet;
|
|
|
|
|
use CodeIgniter\Database\BaseConnection;
|
|
|
|
|
use CodeIgniter\HTTP\IncomingRequest;
|
|
|
|
|
use Config\Services;
|
|
|
|
|
use DateTime;
|
|
|
|
|
use DateTimeZone;
|
|
|
|
|
|
|
|
|
|
class AuditService
|
|
|
|
|
{
|
|
|
|
|
private const TABLE_MAP = [
|
|
|
|
|
'logpatient' => 'logpatient',
|
|
|
|
|
'patient' => 'logpatient',
|
|
|
|
|
'visit' => 'logpatient',
|
|
|
|
|
'logorder' => 'logorder',
|
|
|
|
|
'order' => 'logorder',
|
|
|
|
|
'specimen' => 'logorder',
|
|
|
|
|
'result' => 'logorder',
|
|
|
|
|
'logmaster' => 'logmaster',
|
|
|
|
|
'master' => 'logmaster',
|
|
|
|
|
'config' => 'logmaster',
|
|
|
|
|
'valueset' => 'logmaster',
|
|
|
|
|
'logsystem' => 'logsystem',
|
|
|
|
|
'system' => 'logsystem',
|
|
|
|
|
'auth' => 'logsystem',
|
|
|
|
|
'job' => 'logsystem',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
private const DEFAULT_APP_ID = 'clqms-api';
|
|
|
|
|
private const ENTITY_VERSION_DEFAULT = 1;
|
|
|
|
|
|
|
|
|
|
private static ?BaseConnection $db = null;
|
|
|
|
|
private static $session = null;
|
|
|
|
|
private static ?IncomingRequest $request = null;
|
|
|
|
|
private static ?array $eventIdCache = null;
|
|
|
|
|
private static ?string $cachedRequestId = null;
|
|
|
|
|
|
|
|
|
|
public static function logData(
|
|
|
|
|
string $eventId,
|
|
|
|
|
string $activityId,
|
|
|
|
|
string $entityType,
|
|
|
|
|
string $entityId,
|
|
|
|
|
string $tableName,
|
|
|
|
|
?string $fldName = null,
|
|
|
|
|
$previousValue = null,
|
|
|
|
|
$newValue = null,
|
|
|
|
|
?string $reason = null,
|
|
|
|
|
?array $context = null,
|
|
|
|
|
array $options = []
|
|
|
|
|
): void {
|
|
|
|
|
$sourceTable = $tableName ?: $entityType;
|
|
|
|
|
$logTable = self::resolveLogTable($sourceTable);
|
|
|
|
|
if ($logTable === null) {
|
|
|
|
|
log_message('warning', "AuditService cannot resolve log table for {$sourceTable}");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$record = self::buildRecord(
|
|
|
|
|
$logTable,
|
|
|
|
|
self::normalizeEventId($eventId),
|
|
|
|
|
strtoupper($activityId),
|
|
|
|
|
$entityType,
|
|
|
|
|
$entityId,
|
|
|
|
|
$sourceTable,
|
|
|
|
|
$fldName,
|
|
|
|
|
$previousValue,
|
|
|
|
|
$newValue,
|
|
|
|
|
$reason,
|
|
|
|
|
$context,
|
|
|
|
|
$options
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if ($record === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
self::getDb()->table($logTable)->insert($record);
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
log_message('error', "AuditService failed to insert into {$logTable}: {$e->getMessage()}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function buildRecord(
|
|
|
|
|
string $logTable,
|
|
|
|
|
string $eventId,
|
|
|
|
|
string $activityId,
|
|
|
|
|
string $entityType,
|
|
|
|
|
string $entityId,
|
|
|
|
|
string $tblName,
|
|
|
|
|
?string $fldName,
|
|
|
|
|
$previousValue,
|
|
|
|
|
$newValue,
|
|
|
|
|
?string $reason,
|
|
|
|
|
?array $context,
|
|
|
|
|
array $options
|
|
|
|
|
): ?array {
|
|
|
|
|
$contextJson = self::buildContext($context, $options, $entityType);
|
|
|
|
|
if ($contextJson === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'TblName' => $tblName,
|
|
|
|
|
'RecID' => (string) $entityId,
|
|
|
|
|
'FldName' => $fldName,
|
|
|
|
|
'FldValuePrev' => self::serializeValue($previousValue),
|
|
|
|
|
'FldValueNew' => self::serializeValue($newValue),
|
|
|
|
|
'UserID' => self::resolveUserId($options),
|
|
|
|
|
'SiteID' => self::resolveSiteId($options),
|
|
|
|
|
'DIDType' => $options['did_type'] ?? null,
|
|
|
|
|
'DID' => $options['did'] ?? null,
|
|
|
|
|
'MachineID' => $options['machine_id'] ?? gethostname(),
|
|
|
|
|
'SessionID' => self::resolveSessionId($options),
|
|
|
|
|
'AppID' => $options['app_id'] ?? self::DEFAULT_APP_ID,
|
|
|
|
|
'ProcessID' => $options['process_id'] ?? null,
|
|
|
|
|
'WebPageID' => $options['web_page_id'] ?? self::resolveRoute($options),
|
|
|
|
|
'EventID' => $eventId,
|
|
|
|
|
'ActivityID' => $activityId,
|
|
|
|
|
'Reason' => $reason,
|
|
|
|
|
'LogDate' => self::nowWithMillis(),
|
|
|
|
|
'Context' => $contextJson,
|
|
|
|
|
'IpAddress' => self::resolveIpAddress(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function serializeValue($value): ?string
|
|
|
|
|
{
|
|
|
|
|
if ($value === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (is_scalar($value)) {
|
|
|
|
|
return (string) $value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
|
|
|
return $json !== false ? $json : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function buildContext(?array $context, array $options, string $entityType): ?string
|
|
|
|
|
{
|
|
|
|
|
$route = $options['route'] ?? self::resolveRoute($options);
|
|
|
|
|
$payload = array_merge(
|
|
|
|
|
[
|
|
|
|
|
'request_id' => $options['request_id'] ?? self::resolveRequestId(),
|
|
|
|
|
'route' => $route,
|
|
|
|
|
'timestamp_utc' => $options['timestamp_utc'] ?? self::timestampUtc(),
|
|
|
|
|
'entity_type' => $options['entity_type'] ?? $entityType,
|
|
|
|
|
'entity_version' => $options['entity_version'] ?? self::ENTITY_VERSION_DEFAULT,
|
|
|
|
|
],
|
|
|
|
|
$context ?? []
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
|
|
|
return $json !== false ? $json : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function resolveLogTable(?string $source): ?string
|
|
|
|
|
{
|
|
|
|
|
if ($source === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$key = strtolower(trim($source));
|
|
|
|
|
return self::TABLE_MAP[$key] ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function resolveUserId(array $options): string
|
|
|
|
|
{
|
|
|
|
|
return $options['user_id'] ?? self::getSessionValue('user_id') ?? 'SYSTEM';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function resolveSiteId(array $options): string
|
|
|
|
|
{
|
|
|
|
|
return $options['site_id'] ?? self::getSessionValue('site_id') ?? 'GLOBAL';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function resolveSessionId(array $options): string
|
|
|
|
|
{
|
|
|
|
|
if (!empty($options['session_id'])) {
|
|
|
|
|
return $options['session_id'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$session = self::getSession();
|
|
|
|
|
if ($session !== null && method_exists($session, 'getId')) {
|
|
|
|
|
$id = $session->getId();
|
|
|
|
|
if (!empty($id)) {
|
|
|
|
|
return $id;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
|
|
|
$id = session_id();
|
|
|
|
|
if (!empty($id)) {
|
|
|
|
|
return $id;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return self::generateUniqueId('sess');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function resolveRoute(array $options): string
|
|
|
|
|
{
|
|
|
|
|
if (!empty($options['route'])) {
|
|
|
|
|
return $options['route'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$request = self::getRequest();
|
|
|
|
|
if ($request !== null) {
|
|
|
|
|
return trim(sprintf('%s %s', $request->getMethod(), $request->getUri()->getPath()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'cli';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function resolveIpAddress(): ?string
|
|
|
|
|
{
|
|
|
|
|
$request = self::getRequest();
|
|
|
|
|
if ($request !== null) {
|
|
|
|
|
return $request->getIPAddress();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $_SERVER['REMOTE_ADDR'] ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function normalizeEventId(string $eventId): string
|
|
|
|
|
{
|
|
|
|
|
$normalized = strtoupper(trim($eventId));
|
|
|
|
|
if (empty($normalized)) {
|
|
|
|
|
log_message('warning', 'AuditService received empty EventID');
|
|
|
|
|
return $eventId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!self::isKnownEvent($normalized)) {
|
|
|
|
|
log_message('warning', "AuditService unknown EventID: {$normalized}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function isKnownEvent(string $eventId): bool
|
|
|
|
|
{
|
|
|
|
|
if (self::$eventIdCache === null) {
|
|
|
|
|
$raw = ValueSet::getRaw('event_id') ?? [];
|
|
|
|
|
self::$eventIdCache = array_filter(array_map(fn ($item) => $item['key'] ?? null, $raw));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return in_array($eventId, self::$eventIdCache, true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function resolveRequestId(): string
|
|
|
|
|
{
|
|
|
|
|
if (self::$cachedRequestId !== null) {
|
|
|
|
|
return self::$cachedRequestId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$request = self::getRequest();
|
|
|
|
|
if ($request !== null) {
|
|
|
|
|
$value = $request->getHeaderLine('X-Request-ID');
|
|
|
|
|
if (!empty($value)) {
|
|
|
|
|
self::$cachedRequestId = $value;
|
|
|
|
|
return $value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (['HTTP_X_REQUEST_ID', 'REQUEST_ID'] as $header) {
|
|
|
|
|
if (!empty($_SERVER[$header])) {
|
|
|
|
|
self::$cachedRequestId = $_SERVER[$header];
|
|
|
|
|
return self::$cachedRequestId;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return self::$cachedRequestId = self::generateUniqueId('req');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function nowWithMillis(): string
|
|
|
|
|
{
|
|
|
|
|
$dt = new DateTime('now', new DateTimeZone('UTC'));
|
|
|
|
|
return $dt->format('Y-m-d H:i:s.v');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function timestampUtc(): string
|
|
|
|
|
{
|
|
|
|
|
$dt = new DateTime('now', new DateTimeZone('UTC'));
|
|
|
|
|
return $dt->format('Y-m-d\TH:i:s.v\Z');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function getSessionValue(string $key): ?string
|
|
|
|
|
{
|
|
|
|
|
$session = self::getSession();
|
|
|
|
|
if ($session === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!method_exists($session, 'get')) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$value = $session->get($key);
|
|
|
|
|
return $value !== null ? (string) $value : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function getSession(): ?object
|
|
|
|
|
{
|
|
|
|
|
if (self::$session !== null) {
|
|
|
|
|
return self::$session;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return self::$session = Services::session();
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
return self::$session = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function getRequest(): ?IncomingRequest
|
|
|
|
|
{
|
|
|
|
|
if (self::$request !== null) {
|
|
|
|
|
return self::$request;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return self::$request = Services::request();
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
return self::$request = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function getDb(): BaseConnection
|
|
|
|
|
{
|
|
|
|
|
return self::$db ??= \Config\Database::connect();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function generateUniqueId(string $prefix): string
|
|
|
|
|
{
|
|
|
|
|
try {
|
|
|
|
|
return $prefix . '_' . bin2hex(random_bytes(8));
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
return uniqid("{$prefix}_", true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|