vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/AuditService.php

275 lines
9.0 KiB
PHP
Raw Normal View History

<?php
/**
* VR票务插件 - 审计日志服务
*
* 记录所有敏感操作的防篡改审计日志
*
* @package vr_ticket\service
*/
namespace app\plugins\vr_ticket\service;
class AuditService
{
// ========================
// 操作类型常量(枚举)
// ========================
const ACTION_VERIFY = 'verify'; // 核销票
const ACTION_REFUND = 'refund'; // 退款
const ACTION_EXPORT = 'export'; // 批量导出
const ACTION_DISABLE_VERIFIER= 'disable_verifier'; // 禁用核销员
const ACTION_ENABLE_VERIFIER = 'enable_verifier'; // 启用核销员
const ACTION_DELETE_VERIFIER = 'delete_verifier'; // 删除核销员
const ACTION_DELETE_TEMPLATE = 'delete_template'; // 删除座位模板
const ACTION_DISABLE_TEMPLATE= 'disable_template'; // 禁用座位模板
const ACTION_ENABLE_TEMPLATE = 'enable_template'; // 启用座位模板
const ACTION_ISSUE_TICKET = 'issue_ticket'; // 补发票Phase 3
// ========================
// 对象类型常量
// ========================
const TARGET_TICKET = 'ticket';
const TARGET_VERIFIER = 'verifier';
const TARGET_TEMPLATE = 'seat_template';
const TARGET_GOODS = 'goods';
// ========================
// 日志记录入口(同步写入,异常不阻断主流程)
// ========================
/**
* 记录审计日志
*
* @param string $action 操作类型(使用常量)
* @param string $targetType 对象类型
* @param int $targetId 对象ID
* @param array $extra 附加数据before/after 状态等)
* @param string $targetDesc 对象描述(冗余字段,便于人工查询)
* @return int|false 写入成功返回日志ID失败返回 false
*/
public static function log($action, $targetType, $targetId, $extra = [], $targetDesc = '')
{
try {
$operatorId = self::getOperatorId();
$operatorName = self::getOperatorName();
$clientIp = self::getClientIp();
$userAgent = self::getUserAgent();
$requestId = self::getOrCreateRequestId();
$createdAt = BaseService::now();
$id = \Db::name(BaseService::table('audit_log'))->insertGetId([
'action' => $action,
'operator_id' => $operatorId,
'operator_name' => $operatorName,
'target_type' => $targetType,
'target_id' => $targetId,
'target_desc' => $targetDesc ?: self::buildTargetDesc($targetType, $targetId),
'client_ip' => $clientIp,
'user_agent' => mb_substr($userAgent, 0, 512),
'request_id' => $requestId,
'extra' => empty($extra) ? null : json_encode($extra, JSON_UNESCAPED_UNICODE),
'created_at' => $createdAt,
]);
return $id;
} catch (\Throwable $e) {
// 审计日志写入失败不阻断主业务流程,但记录警告
BaseService::log('AuditService::log failed', [
'action' => $action,
'targetType' => $targetType,
'targetId' => $targetId,
'error' => $e->getMessage(),
], 'warning');
return false;
}
}
// ========================
// 便捷包装方法(核销操作)
// ========================
/**
* 记录核销操作
*/
public static function logVerify($ticketId, $ticketCode, $verifierId, $verifierName, $result, $oldStatus)
{
return self::log(
self::ACTION_VERIFY,
self::TARGET_TICKET,
$ticketId,
[
'ticket_code' => $ticketCode,
'verifier_id' => $verifierId,
'verifier' => $verifierName,
'old_status' => $oldStatus,
'result' => $result,
],
"票码: {$ticketCode}"
);
}
/**
* 记录导出操作
*/
public static function logExport($goodsId, $filter, $count)
{
return self::log(
self::ACTION_EXPORT,
self::TARGET_GOODS,
$goodsId,
[
'filter' => $filter,
'count' => $count,
],
$goodsId > 0 ? "商品ID: {$goodsId}" : '全量导出'
);
}
// ========================
// 查询接口(供管理后台使用)
// ========================
/**
* 查询审计日志(分页)
*
* @param array $params 查询参数action, operator_id, target_type, target_id, date_from, date_to, page, limit
* @return array
*/
public static function search($params = [])
{
$where = [];
if (!empty($params['action'])) {
$where[] = ['action', '=', $params['action']];
}
if (!empty($params['operator_id'])) {
$where[] = ['operator_id', '=', intval($params['operator_id'])];
}
if (!empty($params['target_type'])) {
$where[] = ['target_type', '=', $params['target_type']];
}
if (!empty($params['target_id'])) {
$where[] = ['target_id', '=', intval($params['target_id'])];
}
if (!empty($params['date_from'])) {
$where[] = ['created_at', '>=', strtotime($params['date_from'])];
}
if (!empty($params['date_to'])) {
$where[] = ['created_at', '<=', strtotime($params['date_to'] . ' 23:59:59')];
}
$page = max(1, intval($params['page'] ?? 1));
$pageSize = min(100, max(10, intval($params['limit'] ?? 20)));
$result = \Db::name(BaseService::table('audit_log'))
->where($where)
->order('id', 'desc')
->paginate($pageSize)
->toArray();
// JSON 解析 extra 字段
if (!empty($result['data'])) {
foreach ($result['data'] as &$row) {
if (!empty($row['extra'])) {
$row['extra'] = json_decode($row['extra'], true);
}
}
unset($row);
}
return $result;
}
// ========================
// 内部工具方法
// ========================
/**
* 获取当前操作用户 ID
*/
private static function getOperatorId()
{
// ShopXO admin session: $this->admin['id'] 在控制器中
// 在服务层通过 session() 或 app() 获取
$admin = session('admin');
return isset($admin['id']) ? intval($admin['id']) : 0;
}
/**
* 获取当前操作用户名称
*/
private static function getOperatorName()
{
$admin = session('admin');
return $admin['username'] ?? ($admin['name'] ?? '');
}
/**
* 获取客户端真实 IP
*/
private static function getClientIp()
{
$ip = request()->ip(0, true); // true = 穿透代理
return $ip ?: '';
}
/**
* 获取 User-Agent
*/
private static function getUserAgent()
{
return request()->header('user-agent', '');
}
/**
* 获取或创建请求追踪 ID用于关联同一 HTTP 请求中的多个操作)
*/
private static function getOrCreateRequestId()
{
static $requestId = null;
if ($requestId === null) {
$requestId = session('vr_ticket_request_id');
if (empty($requestId)) {
$requestId = self::generateRequestId();
session('vr_ticket_request_id', $requestId);
}
}
return $requestId;
}
/**
* 生成唯一请求 ID
*/
private static function generateRequestId()
{
return sprintf(
'%s-%s-%04x-%04x-%04x',
date('YmdHis'),
substr(md5(uniqid((string) mt_rand(), true)), 0, 8),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff)
);
}
/**
* 根据对象类型和 ID 构建描述文本
*/
private static function buildTargetDesc($targetType, $targetId)
{
switch ($targetType) {
case self::TARGET_TICKET:
$ticket = \Db::name(BaseService::table('tickets'))->where('id', $targetId)->find();
return $ticket ? "票码: {$ticket['ticket_code']}" : "票ID: {$targetId}";
case self::TARGET_VERIFIER:
$verifier = \Db::name(BaseService::table('verifiers'))->where('id', $targetId)->find();
return $verifier ? "核销员: {$verifier['name']}" : "核销员ID: {$targetId}";
case self::TARGET_TEMPLATE:
$template = \Db::name(BaseService::table('seat_templates'))->where('id', $targetId)->find();
return $template ? "模板: {$template['name']}" : "模板ID: {$targetId}";
default:
return "{$targetType}:{$targetId}";
}
}
}