Files
2meet-data-optimizer-hivepr…/includes/class-tmdo-listing-stats.php
T
wpdev b96b041445 fix(hivepress): detector active-plugin 偵測 + 4 個 interceptor 改繼承核心基底(F2/F3/F4)
F3 detector(A v3.4.5):HivePress addon 不暴露任何 per-addon class/const,
   它們是透過 add_filter('hivepress/v1/extensions') 註冊目錄,所以原本的
   class_exists / defined 偵測永遠只認得 core → 反 EAV 覆蓋率卡在 1/13。
   改以 get_option('active_plugins') 為第一級訊號(新增 active_plugin_files()),
   version_for() 對 addon 改讀其主檔 Version: header。覆蓋率回到 7/13。

F2 interceptor:reviews / messages / memberships / requests 改繼承核心的
   TMDO_Standard_Post_Interceptor,573 → 344 行。FIELD_MAP 由 private
   提升為 public(late static binding 從基底讀取)。
   bootstrap 的 require 迴圈加核心版本守衛:這 4 個檔案在舊核心下會於
   parse 階段就 fatal,class_exists 守衛來不及。

F4 listing-stats:view 計數改用 Zone_Warm::increment() 原子遞增,
   flush 改原子 UPDATE,消除 get()+set() 的 lost-update race。

F5:主檔補 TablePrefix header(打包終檢 §10 schema drift 主路徑)。

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

261 lines
8.0 KiB
PHP

<?php
/**
* Listing stats integration for Zone B view counting.
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone B/D integration — Listing Statistics & Archival.
*
* Zone B (Warm): Tracks per-listing page view counts with a 24-hour TTL.
* - Reads from wpdo_warm first; falls back to postmeta if no warm entry.
* - Increments on each front-end singular hp_listing page load (non-admin, non-bot).
* - Keeps postmeta clean by flushing accumulated counts via cron (wpdo_flush_views).
*
* Zone D (Archive): Archives hot-zone fields for expired listings.
* - Triggered by the existing wpdo_archive_sweep cron (daily).
* - Finds listings where hp_expired_time < now and expired > TMDO_ARCHIVE_DAYS ago.
* - Archives hp_price, hp_featured, hp_verified, hp_expired_time with gzip compression.
*/
class TMDO_Listing_Stats {
/** Meta key used for the warm-zone view counter. */
const VIEW_KEY = 'wpdo_views';
/** TTL for warm-zone view entries (24 hours). */
const VIEW_TTL = DAY_IN_SECONDS;
/** How many days after expiry before archiving hot fields. */
const ARCHIVE_DAYS = 30;
/** Hot-zone meta keys to archive for expired listings. */
const ARCHIVE_KEYS = array( 'hp_price', 'hp_featured', 'hp_verified', 'hp_expired_time' );
/**
* Register all hooks.
*/
public static function register_hooks(): void {
// Zone B: increment view count on front-end singular listing pages.
add_action( 'wp', array( __CLASS__, 'maybe_increment_view' ) );
// Zone B: flush accumulated warm-zone counts to postmeta (hourly).
add_action( 'wpdo_flush_views', array( __CLASS__, 'flush_views_to_postmeta' ) );
// Zone D: archive expired listing fields (daily cron via wpdo_archive_sweep).
add_action( 'wpdo_archive_sweep', array( __CLASS__, 'archive_expired_listings' ), 20 );
}
/**
* Schedule the flush cron if not already registered.
*
* Called from TMDO_Core::schedule_cron().
*/
public static function schedule_cron(): void {
if ( ! wp_next_scheduled( 'wpdo_flush_views' ) ) {
wp_schedule_event( time(), 'hourly', 'wpdo_flush_views' );
}
}
// ── Zone B: View Counter ─────────────────────────────────────────────────
/**
* Increment the warm-zone view counter for the current listing page.
*
* Only fires on front-end, singular hp_listing pages, for non-bot requests.
*/
public static function maybe_increment_view(): void {
if ( is_admin() || ! is_singular( 'hp_listing' ) ) {
return;
}
// Skip common bots by checking for empty or known bot user-agents.
$ua = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
if ( empty( $ua ) || preg_match( '/bot|crawl|slurp|spider|mediapartners/i', $ua ) ) {
return;
}
$post_id = (int) get_the_ID();
if ( $post_id <= 0 ) {
return;
}
self::increment_view( $post_id );
}
/**
* Increment the warm-zone view count for a listing.
*
* @param int $post_id Listing post ID.
*/
public static function increment_view( int $post_id ): void {
TMDO_Zone_Warm::increment( $post_id, self::VIEW_KEY, 1, self::VIEW_TTL );
}
/**
* Read the view count for a listing.
*
* Returns warm-zone value if available; falls back to postmeta hp_view_count.
*
* @param int $post_id Listing post ID.
* @return int
*/
public static function get_view_count( int $post_id ): int {
$warm = TMDO_Zone_Warm::get( $post_id, self::VIEW_KEY );
if ( null !== $warm ) {
return (int) $warm;
}
return (int) get_post_meta( $post_id, 'hp_view_count', true );
}
/**
* Flush all warm-zone view counts to postmeta and reset warm entries.
*
* Called hourly by wpdo_flush_views cron.
*
* @return int Number of listings flushed.
*/
public static function flush_views_to_postmeta(): int {
global $wpdb;
$table = TMDO_Zone_Warm::table();
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_value FROM `{$table}` WHERE meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
self::VIEW_KEY
),
ARRAY_A
);
if ( ! $rows ) {
return 0;
}
$flushed = 0;
foreach ( $rows as $row ) {
$post_id = (int) $row['post_id'];
$new_views = (int) $row['meta_value'];
// Atomic increment: avoids the read-modify-write race that could lose
// concurrent view increments arriving between our batch-read and the write.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- WPDO.AntiEAV.no-direct-postmeta-update: view-count sync, no meta API equivalent for atomic add.
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE `{$wpdb->postmeta}` SET meta_value = meta_value + %d WHERE post_id = %d AND meta_key = 'hp_view_count'",
$new_views,
$post_id
)
);
// Seed the key when it does not exist yet.
if ( ! $updated ) {
add_post_meta( $post_id, 'hp_view_count', $new_views, true );
}
// Remove the warm entry after flush.
TMDO_Zone_Warm::delete( $post_id, self::VIEW_KEY );
++$flushed;
}
return $flushed;
}
// ── Zone D: Expired Listing Archival ─────────────────────────────────────
/**
* Archive hot-zone fields for listings that expired ARCHIVE_DAYS ago.
*
* Reads hp_expired_time from postmeta (or hot zone via Sync Bridge).
* Archives ARCHIVE_KEYS with gzip compression into wpdo_archive.
*
* @param int $limit Max listings to process per run.
* @return int Number of listing fields archived.
*/
public static function archive_expired_listings( int $limit = 200 ): int {
global $wpdb;
$cutoff = (int) ( time() - self::ARCHIVE_DAYS * DAY_IN_SECONDS );
// Find published listings with hp_expired_time before cutoff.
$listings = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT pm.post_id
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = 'hp_expired_time'
AND pm.meta_value > '0'
AND CAST(pm.meta_value AS UNSIGNED) < %d
AND p.post_type = 'hp_listing'
AND p.post_status IN ('publish','private')
LIMIT %d",
$cutoff,
$limit
)
);
if ( ! $listings ) {
return 0;
}
$listing_ids = array_map( 'intval', $listings );
$id_list = implode( ',', $listing_ids );
$key_list = implode( ',', array_fill( 0, count( self::ARCHIVE_KEYS ), '%s' ) );
// Batch-fetch all relevant postmeta rows in one query.
$meta_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_id, post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id IN ({$id_list}) AND meta_key IN ({$key_list})", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $wpdb->postmeta is core. phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Archive sweep: must read postmeta to migrate to Zone D.
...self::ARCHIVE_KEYS
),
ARRAY_A
);
// Build lookup: $meta_map[ post_id ][ meta_key ] = [ meta_id, meta_value ].
$meta_map = array();
foreach ( $meta_rows as $mr ) {
$meta_map[ (int) $mr['post_id'] ][ $mr['meta_key'] ] = array(
'meta_id' => (int) $mr['meta_id'],
'meta_value' => $mr['meta_value'],
);
}
$archived = 0;
foreach ( $listing_ids as $post_id ) {
foreach ( self::ARCHIVE_KEYS as $meta_key ) {
if ( ! isset( $meta_map[ $post_id ][ $meta_key ] ) ) {
continue;
}
$entry = $meta_map[ $post_id ][ $meta_key ];
$meta_value = $entry['meta_value'];
if ( '' === $meta_value || false === $meta_value ) {
continue;
}
TMDO_Zone_Archive::archive(
$post_id,
'hp_listing',
$meta_key,
(string) $meta_value,
$entry['meta_id'],
true // compress.
);
++$archived;
}
}
return $archived;
}
/**
* Get archive statistics for expired listings.
*
* @return array{total_rows: int, compressed_rows: int, post_types: array}
*/
public static function stats(): array {
return TMDO_Zone_Archive::stats();
}
}