275 lines
9.1 KiB
PHP
275 lines
9.1 KiB
PHP
<?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 = \think\facade\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 = \think\facade\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 = \think\facade\Db::name(BaseService::table('tickets'))->where('id', $targetId)->find();
|
||
return $ticket ? "票码: {$ticket['ticket_code']}" : "票ID: {$targetId}";
|
||
case self::TARGET_VERIFIER:
|
||
$verifier = \think\facade\Db::name(BaseService::table('verifiers'))->where('id', $targetId)->find();
|
||
return $verifier ? "核销员: {$verifier['name']}" : "核销员ID: {$targetId}";
|
||
case self::TARGET_TEMPLATE:
|
||
$template = \think\facade\Db::name(BaseService::table('seat_templates'))->where('id', $targetId)->find();
|
||
return $template ? "模板: {$template['name']}" : "模板ID: {$targetId}";
|
||
default:
|
||
return "{$targetType}:{$targetId}";
|
||
}
|
||
}
|
||
}
|