prefix . TMDO_TABLE_PREFIX . self::TABLE_SUFFIX; } public static function init(): void { add_action( 'wpdo_after_write', array( self::class, 'on_write' ), 10, 7 ); add_action( 'wpdo_after_delete', array( self::class, 'on_delete' ), 10, 7 ); } /** * 寫入後訂閱 — 記錄 value_before / value_after 至 audit log。 * * @param string $entity_type post|user|term|comment * @param int $entity_id * @param string $meta_key * @param mixed $meta_value 寫入後的新值 * @param mixed $result perform_upsert 回傳值 * @param string $op 'add' | 'update' * @param mixed $before_value 寫入前的舊值(v1.3.1+ 由 hook_bus 傳入) */ public static function on_write( string $entity_type, int $entity_id, string $meta_key, $meta_value, $result, string $op, $before_value = null ): void { if ( $result === false ) { return; // 寫入失敗不記 } $field_def = TMDO_Entity_Registry::get_field( $entity_type, $meta_key ); if ( ! $field_def ) { return; // 非 UAE 欄位 } self::write_row( array( 'entity_type' => $entity_type, 'entity_id' => $entity_id, 'group_name' => (string) ( $field_def['group'] ?? '' ), 'meta_key' => $meta_key, 'action' => 'write', 'op' => $op, // 'add' | 'update' 'value_before' => self::stringify( $before_value ), 'value_after' => self::stringify( $meta_value ), ) ); } /** * 刪除後訂閱。 * * @param string $entity_type * @param int $entity_id * @param string $meta_key * @param mixed $meta_value WP 傳進 delete_metadata 的值(語義:匹配這個值才刪;不等於實際刪除前的 flat 值) * @param mixed $result * @param bool $delete_all * @param mixed $before_value 刪除前 flat table 實際的值(v1.3.1+) */ public static function on_delete( string $entity_type, int $entity_id, string $meta_key, $meta_value, $result, bool $delete_all, $before_value = null ): void { if ( $result === false ) { return; } $field_def = TMDO_Entity_Registry::get_field( $entity_type, $meta_key ); if ( ! $field_def ) { return; } self::write_row( array( 'entity_type' => $entity_type, 'entity_id' => $entity_id, 'group_name' => (string) ( $field_def['group'] ?? '' ), 'meta_key' => $meta_key, 'action' => 'delete', 'op' => $delete_all ? 'delete_all' : 'delete', 'value_before' => self::stringify( $before_value ), 'value_after' => null, ) ); } private static function write_row( array $row ): void { global $wpdb; $source = apply_filters( 'wpdo_audit_source', defined( 'WP_CLI' ) && WP_CLI ? self::SOURCE_CLI : ( defined( 'REST_REQUEST' ) && REST_REQUEST ? self::SOURCE_REST : self::SOURCE_INTERNAL ) ); // wpdb::insert 以欄位順序對應 format — 我們用 null 讓 wpdb 自己根據值型別推斷, // 避免 format array 長度與 row key 數不對稱(v1.5.1 加 op 後更容易出錯)。 $wpdb->insert( self::table_name(), array_merge( $row, array( 'ts' => current_time( 'mysql', true ), 'user_id' => get_current_user_id(), 'source' => (string) $source, 'trace_id' => TMDO_Logger::trace_id(), ) ) ); // 1% 機率觸發 prune — 類似 shadow_diff_logger::maybe_trim, // 避免每次寫都查 count。 if ( wp_rand( 1, 100 ) === 1 ) { self::maybe_prune(); } } /** * 依 MAX_ROWS 與 retention_days 裁剪 audit 表。 * * 先按時間刪除過期資料,再看是否超過 MAX_ROWS,超過則刪除最舊。 * 回傳總共刪除的 row 數(供 CLI 呈現)。 * * @since 1.3.2 */ public static function maybe_prune(): int { global $wpdb; $table = self::table_name(); $total = 0; $retention_days = (int) get_option( self::OPT_RETENTION_DAYS, self::DEFAULT_RETENTION_DAYS ); if ( $retention_days > 0 ) { $cutoff = gmdate( 'Y-m-d H:i:s', time() - $retention_days * DAY_IN_SECONDS ); $deleted = (int) $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE ts < %s", $cutoff ) ); $total += $deleted; if ( $deleted > 0 ) { TMDO_Logger::info( 'audit_prune_expired', array( 'deleted' => $deleted, 'cutoff' => $cutoff, 'days' => $retention_days, ) ); } } $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); if ( $count > self::MAX_ROWS ) { $to_delete = $count - self::MAX_ROWS; $deleted = (int) $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` ORDER BY id ASC LIMIT %d", $to_delete ) ); $total += $deleted; TMDO_Logger::warning( 'audit_prune_overflow', array( 'deleted' => $deleted, 'count_before' => $count, 'max_rows' => self::MAX_ROWS, ) ); } return $total; } private static function stringify( $value ): ?string { if ( $value === null ) { return null; } if ( is_scalar( $value ) ) { return (string) $value; } return (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); } // ───────────────────────────────────────────────────────── // Schema // ───────────────────────────────────────────────────────── public static function install_table(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table_name(); $charset = $wpdb->get_charset_collate(); // `action` 在 MySQL 部分版本為 reserved keyword — 用 backtick 保險。 // v1.5.1 新增 `op` 欄位:細分 WordPress 內部觸發路徑(add / update / delete), // 用來區分「add_metadata 與 update_metadata 被同時觸發」的雙 row 情境。 $sql = "CREATE TABLE {$table} ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `ts` DATETIME NOT NULL, `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0, `entity_type` VARCHAR(20) NOT NULL, `entity_id` BIGINT UNSIGNED NOT NULL, `group_name` VARCHAR(64) NOT NULL, `meta_key` VARCHAR(255) NOT NULL, `action` VARCHAR(10) NOT NULL, `op` VARCHAR(10) NOT NULL DEFAULT 'update', `value_before` LONGTEXT NULL, `value_after` LONGTEXT NULL, `source` VARCHAR(20) NULL, `trace_id` CHAR(36) NULL, PRIMARY KEY (`id`), KEY `idx_entity` (`entity_type`, `entity_id`), KEY `idx_ts` (`ts`), KEY `idx_user` (`user_id`, `ts`), KEY `idx_op` (`op`, `ts`) ) {$charset};"; dbDelta( $sql ); } public static function drop_table(): void { global $wpdb; $wpdb->query( 'DROP TABLE IF EXISTS ' . self::table_name() ); } // ───────────────────────────────────────────────────────── // Read API(供 admin / CLI 查詢) // ───────────────────────────────────────────────────────── /** * 取得最近 N 筆 audit 紀錄。 * * @return array> */ public static function recent( int $limit = 100, ?string $entity_type = null ): array { global $wpdb; $table = self::table_name(); if ( $entity_type ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM `{$table}` WHERE entity_type = %s ORDER BY id DESC LIMIT %d", $entity_type, $limit ), ARRAY_A ); } else { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d", $limit ), ARRAY_A ); } return is_array( $rows ) ? $rows : array(); } public static function count(): int { global $wpdb; return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::table_name() . '`' ); } }