码桶

发现社区成员的开源项目

geekssl /

wstats

公开
main
wstats/app/Support/OpLog.php
OpLog.php7.5 KB
<?php
/**
 * 用户操作日志(个人中心「我的日志」/ 用户管理审计)
 *
 * 原则:
 *  - 只追加、不修改;detail 是给人看的一句话(≤500 字符,超长截断);
 *  - 表缺失(老库未跑增量 SQL)时**静默跳过**,绝不因日志写入失败阻断业务主流程
 *    (登录/改密等调用方不应感知本类的存在);
 *  - IP 取值统一走 Util::clientIp()(手动指定来源,见系统设置 → 真实 IP 采集)。
 */
declare(strict_types=1);

namespace Wstat\Support;

class OpLog
{
    /** 全部动作枚举(新动作必须加在这里,前端按 key 翻译) */
    public const ACTIONS = [
        'login',              // 登录成功
        'logout',             // 退出登录
        'profile_update',     // 修改个人资料
        'password_change',    // 本人修改密码
        'password_reset',     // 通过邮箱验证码重置密码
        'user_create',        // 管理员创建用户
        'user_update',        // 管理员更新用户
        'user_disable',       // 管理员禁用用户
        'user_enable',        // 管理员启用用户
        'user_delete',        // 管理员删除用户
        'user_reset_password',// 管理员重置用户密码(记到被操作者名下)
    ];

    private static ?bool $tableOk = null;

    /** 上一次探测失败的原因(供「我的操作日志」页面诊断展示) */
    private static string $lastErr = '';

    /**
     * user_op_logs 表是否可用(进程内缓存一次)。
     *
     * 探测走 `SHOW COLUMNS`(即 Db::tableColumns),**不用 information_schema**:
     *   · 共享主机 / 云数据库常见「查不了 information_schema」或 `DATABASE()` 取空,
     *     用它判表会把「表明明建好了」误判成缺表,页面一直提示去执行 SQL(真实故障);
     *   · SHOW COLUMNS 是直接对着目标表发问 —— 表在就成功、不在就抛 1146,
     *     结论与后续真正的读写行为完全一致,不存在探测与使用两套口径。
     * 同时要求三个必需列存在:表建错结构(例如手工改了列名)也算不可用,
     * 否则探测通过而写入静默失败,问题会更难查。
     */
    public static function available(): bool
    {
        if (self::$tableOk === null) {
            try {
                $cols = Db::tableColumns('user_op_logs');
                self::$tableOk = isset($cols['user_id'], $cols['action'], $cols['created_at']);
                if (!self::$tableOk) {
                    self::$lastErr = '表存在,但缺少必需列:user_id / action / created_at';
                }
            } catch (\Throwable $e) {
                self::$tableOk = false;
                self::$lastErr = $e->getMessage();
            }
        }
        return self::$tableOk;
    }

    /** 当前连接实际选中的数据库名(诊断用;取不到返回空串) */
    public static function dbName(): string
    {
        try {
            return (string) Db::value('SELECT DATABASE()');
        } catch (\Throwable $e) {
            return '';
        }
    }

    /** 建表语句(表缺失时原样发给前端,让用户复制到**当前库**执行,避免手抄出错) */
    public static function ddl(): string
    {
        return "CREATE TABLE IF NOT EXISTS `user_op_logs` (\n"
            . "  `id`         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n"
            . "  `user_id`    INT UNSIGNED    NOT NULL,\n"
            . "  `action`     VARCHAR(40)     NOT NULL,\n"
            . "  `detail`     VARCHAR(500)    NOT NULL DEFAULT '',\n"
            . "  `ip`         VARCHAR(45)     NOT NULL DEFAULT '',\n"
            . "  `ua`         VARCHAR(255)    NOT NULL DEFAULT '',\n"
            . "  `created_at` INT UNSIGNED    NOT NULL,\n"
            . "  PRIMARY KEY (`id`),\n"
            . "  KEY `idx_user_time` (`user_id`, `created_at`)\n"
            . ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
    }

    /** 重置进程内缓存(测试用例切换库后调用) */
    public static function resetCache(): void
    {
        self::$tableOk = null;
        self::$lastErr = '';
    }

    /**
     * 记一条操作日志。任何失败都静默返回 false,不影响主流程。
     * @param int    $userId 归属用户(管理员操作时=被操作者;同时想给操作者留痕就再调一次)
     * @param string $action self::ACTIONS 之一
     * @param string $detail 人读的一句话说明(可为空)
     */
    public static function log(int $userId, string $action, string $detail = ''): bool
    {
        if ($userId <= 0 || !in_array($action, self::ACTIONS, true) || !self::available()) {
            return false;
        }
        try {
            Db::insert('user_op_logs', Db::filterColumns('user_op_logs', [
                'user_id'    => $userId,
                'action'     => $action,
                'detail'     => Util::cut(trim($detail), 500),
                'ip'         => Util::clientIp(),
                'ua'         => Util::cut((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 255),
                'created_at' => time(),
            ]));
            return true;
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * 某用户的日志分页(倒序)。表缺失时降级为空集并带 logs_table=false 标记,
     * 前端据此提示「请先执行增量升级 SQL」而非显示一个永远空白的列表。
     *
     * logs_table=false 时额外返回诊断三件套,让用户自己就能定位(不必来问「我明明执行了」):
     *   db  —— 服务端**当前连的是哪个库**(把 SQL 执行到了别的库是最常见的原因);
     *   err —— 探测/查询失败的真实原因(如 1146 Table doesn't exist);
     *   sql —— 建表语句原文,前端可一键复制,保证用户执行的就是代码里这一份。
     * @return array{items:array,total:int,page:int,page_size:int,logs_table:bool,db:string,err:string,sql:string}
     */
    public static function page(int $userId, int $page = 1, int $pageSize = 20): array
    {
        $page = max(1, $page);
        $pageSize = min(100, max(5, $pageSize));
        $out = [
            'items' => [], 'total' => 0, 'page' => $page, 'page_size' => $pageSize,
            'logs_table' => true, 'db' => self::dbName(), 'err' => '', 'sql' => '',
        ];
        if ($userId <= 0 || !self::available()) {
            return self::degrade($out, self::$lastErr);
        }
        try {
            $total = (int) Db::value('SELECT COUNT(*) FROM user_op_logs WHERE user_id=?', [$userId]);
            $items = [];
            if ($total > 0) {
                $offset = ($page - 1) * $pageSize;
                $items = Db::select(
                    'SELECT id,action,detail,ip,ua,created_at FROM user_op_logs
                     WHERE user_id=? ORDER BY id DESC LIMIT ' . $pageSize . ' OFFSET ' . $offset,
                    [$userId]
                );
            }
            $out['total'] = $total;
            $out['items'] = $items;
            return $out;
        } catch (\Throwable $e) {
            return self::degrade($out, $e->getMessage());
        }
    }

    /** 降级结果:标记缺表并补齐诊断信息(db 已在 page() 初始化时填好) */
    private static function degrade(array $out, string $err): array
    {
        $out['logs_table'] = false;
        $out['err']        = Util::cut($err, 300);
        $out['sql']        = self::ddl();
        return $out;
    }
}