Files
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:13:33 +08:00

325 lines
10 KiB
PHP

<?php
/**
* TMDO_Audit_Logger — 合規稽核用獨立 audit log 表。
*
* 訂閱 v1.2.0 起新增的 `wpdo_after_write` / `wpdo_after_delete` action,
* 把每次寫入/刪除的 who/when/what/before/after 寫進 `wp_wpdo_uni_audit`,
* 與 shadow_diffs 分離以利長期保留與合規查詢(GDPR / SOX 等)。
*
* 設計決定:
* - 同步寫入(不走 Action Scheduler)以確保 audit 先於下游動作。
* 若效能成為瓶頸,未來可改 fire-and-forget(wp_schedule_single_event)。
* - `value_before` 透過讀取 flat table 取得,若表不存在則留 null。
* - `source` 欄位以 const 列出可能的呼叫來源(rest / cli / fe-editor / internal);
* 由呼叫端透過 filter `wpdo_audit_source` 覆寫。
*
* @package WP_Data_Optimizer
* @since 1.3.0
*/
declare(strict_types=1);
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Audit_Logger {
public const TABLE_SUFFIX = 'audit';
/** 硬性 row 上限,超過時 maybe_prune 會刪除最舊的資料。 */
public const MAX_ROWS = 500_000;
/** 預設保留天數(可由 `wpdo_audit_retention_days` option 覆寫)。 */
public const DEFAULT_RETENTION_DAYS = 365;
public const OPT_RETENTION_DAYS = 'wpdo_audit_retention_days';
public const SOURCE_REST = 'rest';
public const SOURCE_CLI = 'cli';
public const SOURCE_FE_EDITOR = 'fe-editor';
public const SOURCE_INTERNAL = 'internal';
public static function table_name(): string {
global $wpdb;
return $wpdb->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<int, array<string, mixed>>
*/
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() . '`' );
}
}