prefix}mowl_events` * literálként áll (nem ezen a konstanson át): a Plugin Check * UnescapedDBParameter sniffje csak a $wpdb-tulajdonságos interpolációt * fogadja el biztonságosnak, egy metódusból kapott $table változót nem. * Aki átnevezi a táblát, a literálokat is cserélje. */ public const TABLE_SUFFIX = 'mowl_events'; /** * '2': type_order (type, order_id) index a has_event() alá. * * Csak ennek a táblának (`mowl_events`) a sémáját verziózza — a * `mowl_carts` tábla a saját `CartStore::DB_VERSION`-jét és * `mowl_carts_db_version` optionjét viszi, ezt nem kell (és nem szabad) * bumpolni a kosár-tábla sémaváltozásai miatt. */ public const DB_VERSION = '2'; /** A napló-sorok megtartása: ennyi nap és legfeljebb ennyi legújabb sor. */ public const LOG_DAYS = 30; public const LOG_KEEP = 500; /** Átlagosan minden ennyiedik log()-hívás után trimmelünk. */ public const LOG_TRIM_EVERY = 20; /** * Teszt-injektálható döntő: igaz, ha az adott log()-hívás után trimmelni * kell. null = a beépített véletlen (1/LOG_TRIM_EVERY). * * @var callable|null */ private static $log_trim_decider = null; public const STATUS_PENDING = 'pending'; public const STATUS_SENT = 'sent'; public const STATUS_FAILED = 'failed'; public const STATUS_DEAD = 'dead'; public const TYPE_LOG = 'log'; public static function table(): string { global $wpdb; return $wpdb->prefix . self::TABLE_SUFFIX; } public static function install(): void { global $wpdb; if ( get_option( 'mowl_db_version' ) === self::DB_VERSION ) { return; } require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$wpdb->prefix}mowl_events ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, event_id char(36) NOT NULL, type varchar(32) NOT NULL, order_id bigint(20) unsigned NULL, email varchar(191) NOT NULL, payload longtext NOT NULL, status varchar(16) NOT NULL DEFAULT 'pending', attempts tinyint unsigned NOT NULL DEFAULT 0, next_attempt_at datetime NULL, last_error varchar(191) NULL, created_at datetime NOT NULL, sent_at datetime NULL, PRIMARY KEY (id), UNIQUE KEY event_id (event_id), KEY status_next (status, next_attempt_at), KEY type_id (type, id), KEY type_order (type, order_id), KEY created_at (created_at) ) {$charset};" ); // Az 1.0 előtti napló az options-táblában élt (mowl_log); a sorok // azóta ebben a táblában vannak, a régi option csak helyet foglalna. delete_option( Options::LOG ); // Autoload: install() minden kérésen lefut (plugins_loaded), a // verzió-ellenőrzése get_option()-t hív — ha az option nem autoload, // ez perzisztens object cache nélkül egy külön SELECT-et jelent // minden egyes oldalletöltésen. update_option( 'mowl_db_version', self::DB_VERSION ); } public static function drop(): void { global $wpdb; $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}mowl_events" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL -- uninstall, azonosító nem paraméterezhető delete_option( 'mowl_db_version' ); } public static function enqueue( string $type, string $email, array $payload, ?int $order_id = null ): string { global $wpdb; $event_id = wp_generate_uuid4(); $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla self::table(), array( 'event_id' => $event_id, 'type' => $type, 'order_id' => $order_id, 'email' => $email, // Érvénytelen UTF-8 (pl. egy rosszul kódolt terméknév) U+FFFD-re // cserélve, hogy az encode ne adjon false-t és a sor ne vesszen el. 'payload' => wp_json_encode( $payload, JSON_INVALID_UTF8_SUBSTITUTE ), 'status' => self::STATUS_PENDING, 'attempts' => 0, 'created_at' => current_time( 'mysql', true ), ), array( '%s', '%s', '%d', '%s', '%s', '%s', '%d', '%s' ) ); return $event_id; } /** * Van-e már élő (nem failed, nem dead) sor ehhez a típushoz és * rendeléshez — pending vagy sent. A klasszikus pénztár újraküldése * ugyanarra a rendelésre másodszor is kiváltja a * woocommerce_checkout_order_processed-et; a hívó ezzel dedupliál. */ public static function has_event( string $type, int $order_id ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az értékek prepare-relve $id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}mowl_events WHERE type = %s AND order_id = %d AND status NOT IN (%s, %s) LIMIT 1", $type, $order_id, self::STATUS_FAILED, self::STATUS_DEAD ) ); return null !== $id; } /** * Ment-e (vagy vár-e a sorban) ilyen típusú esemény ehhez a címhez a * megadott időpont óta? A CartSweeper címenkénti napi korlátja használja * (I8) — a `type_id` index (type, id) előtagja szűri a típust, a cím * összehasonlítása a tábla alapértelmezett, kisbetű-érzéketlen kollációján * történik. A sor státusza nem számít: a szándék akkor is megvolt, ha a * küldés még pending vagy épp visszapróbálás alatt áll. */ public static function has_recent_event( string $type, string $email, string $since ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az értékek prepare-relve return null !== $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}mowl_events WHERE type = %s AND email = %s AND created_at >= %s LIMIT 1", $type, $email, $since ) ); } /** * Az esedékes pending sorok. * * @param list|null $types null = minden típus; egyébként a típusszűrő. * @param bool $exclude false: csak a megadott típusok; true: azok kivételével. */ public static function claim_due( int $limit = 100, ?array $types = null, bool $exclude = false ): array { global $wpdb; $args = array( self::STATUS_PENDING, current_time( 'mysql', true ) ); if ( null === $types || array() === $types ) { $args[] = $limit; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- saját tábla; a táblanév nem paraméterezhető, az értékek prepare-relve return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}mowl_events WHERE status = %s AND (next_attempt_at IS NULL OR next_attempt_at <= %s) ORDER BY created_at ASC, id ASC LIMIT %d", ...$args ) ); } $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $args = array_merge( $args, array_values( $types ), array( $limit ) ); if ( $exclude ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- saját tábla; a dinamikus IN(...) helyőrzők száma futásidőben megegyezik a $types elemszámával return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}mowl_events WHERE status = %s AND (next_attempt_at IS NULL OR next_attempt_at <= %s) AND type NOT IN ({$placeholders}) ORDER BY created_at ASC, id ASC LIMIT %d", ...$args ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- saját tábla; a dinamikus IN(...) helyőrzők száma futásidőben megegyezik a $types elemszámával return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}mowl_events WHERE status = %s AND (next_attempt_at IS NULL OR next_attempt_at <= %s) AND type IN ({$placeholders}) ORDER BY created_at ASC, id ASC LIMIT %d", ...$args ) ); } /** @param list $event_ids */ public static function mark_sent( array $event_ids ): void { global $wpdb; if ( array() === $event_ids ) { return; } $placeholders = implode( ',', array_fill( 0, count( $event_ids ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- a táblanév saját; a dinamikus IN(...) helyőrzők száma (a $placeholders) futásidőben megegyezik az event_id-k számával, a sniff statikusan nem tudja megszámolni $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}mowl_events SET status = %s, sent_at = %s, last_error = NULL WHERE event_id IN ({$placeholders})", array_merge( array( self::STATUS_SENT, current_time( 'mysql', true ) ), $event_ids ) ) ); } public static function mark_failed( string $event_id, string $error ): void { self::update_status( $event_id, self::STATUS_FAILED, array( 'last_error' => mb_substr( $error, 0, 191 ) ) ); } public static function mark_dead( string $event_id, string $error ): void { self::update_status( $event_id, self::STATUS_DEAD, array( 'last_error' => mb_substr( $error, 0, 191 ) ) ); } public static function mark_retry( string $event_id, int $attempts, string $next_attempt_at, string $error ): void { self::update_status( $event_id, self::STATUS_PENDING, array( 'attempts' => $attempts, 'next_attempt_at' => $next_attempt_at, 'last_error' => mb_substr( $error, 0, 191 ), ) ); } /** @return array{pending: int, sent_24h: int, failed: int, dead: int} */ public static function counts(): array { global $wpdb; $since = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row = $wpdb->get_row( $wpdb->prepare( "SELECT SUM(status = 'pending') AS pending, SUM(status = 'sent' AND sent_at >= %s) AS sent_24h, SUM(status = 'failed') AS failed, SUM(status = 'dead') AS dead FROM {$wpdb->prefix}mowl_events WHERE type <> %s", $since, self::TYPE_LOG ), ARRAY_A ); return array( 'pending' => (int) ( $row['pending'] ?? 0 ), 'sent_24h' => (int) ( $row['sent_24h'] ?? 0 ), 'failed' => (int) ( $row['failed'] ?? 0 ), 'dead' => (int) ( $row['dead'] ?? 0 ), ); } /** @return array */ public static function recent( int $limit = 50 ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return (array) $wpdb->get_results( $wpdb->prepare( "SELECT event_id, type, order_id, email, status, attempts, last_error, created_at, sent_at FROM {$wpdb->prefix}mowl_events WHERE type <> %s ORDER BY id DESC LIMIT %d", self::TYPE_LOG, $limit ) ); } /** * Minden esemény-sor törlése (a napló marad). Szétkapcsoláskor / másik * workspace-re kapcsoláskor: a függő sorok különben a következő * workspace-be mennének át. */ public static function purge_events(): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az érték prepare-relve return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}mowl_events WHERE type <> %s", self::TYPE_LOG ) ); } public static function retry_dead(): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return (int) $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}mowl_events SET status = %s, attempts = 0, next_attempt_at = NULL WHERE status = %s", self::STATUS_PENDING, self::STATUS_DEAD ) ); } /** * Az elküldött és a (Mowl által érvénytelennek visszadobott) failed sorok, * továbbá a napló-sorok törlése a megadott napnál régebbről. A failed sor * sent_at nélkül áll, ott a created_at a kor. */ public static function prune_sent( int $days = 30 ): int { global $wpdb; $before = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az értékek prepare-relve return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}mowl_events WHERE (status = %s AND sent_at < %s) OR (status = %s AND created_at < %s) OR (type = %s AND created_at < %s)", self::STATUS_SENT, $before, self::STATUS_FAILED, $before, self::TYPE_LOG, $before ) ); } /** * Napló-sor. A tábla WooCommerce nélkül is nő (health-hibák stb.), a napi * prune viszont csak WC + kapcsolat + sync mellett fut — ezért a napló * itt, beszúrás után, átlagosan minden LOG_TRIM_EVERY-edik híváson * trimmeli magát (LOG_DAYS napnál régebbi és a LOG_KEEP legújabbon túli sorok). */ public static function log( string $level, string $message, array $context = array() ): void { global $wpdb; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla self::table(), array( 'event_id' => wp_generate_uuid4(), 'type' => self::TYPE_LOG, 'email' => '', 'payload' => wp_json_encode( array( 'message' => $message, 'context' => $context, ), JSON_INVALID_UTF8_SUBSTITUTE ), 'status' => sanitize_key( $level ), 'created_at' => current_time( 'mysql', true ), ), array( '%s', '%s', '%s', '%s', '%s', '%s' ) ); if ( self::should_trim_logs() ) { self::trim_logs(); } } /** Teszt-horog: a log() utáni trim döntője (null = a beépített véletlen). */ public static function set_log_trim_decider( ?callable $decider ): void { self::$log_trim_decider = $decider; } private static function should_trim_logs(): bool { if ( null !== self::$log_trim_decider ) { return (bool) call_user_func( self::$log_trim_decider ); } return 1 === wp_rand( 1, self::LOG_TRIM_EVERY ); } /** A LOG_DAYS napnál régebbi, és a LOG_KEEP legújabbon túli napló-sorok törlése. */ public static function trim_logs( int $days = self::LOG_DAYS, int $keep = self::LOG_KEEP ): int { global $wpdb; $before = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az értékek prepare-relve $deleted = (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}mowl_events WHERE type = %s AND created_at < %s", self::TYPE_LOG, $before ) ); // A (keep+1)-edik legújabb napló-sor id-je: az és a nála régebbiek mennek. // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $cutoff = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}mowl_events WHERE type = %s ORDER BY id DESC LIMIT 1 OFFSET %d", self::TYPE_LOG, $keep ) ); if ( null !== $cutoff ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $deleted += (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}mowl_events WHERE type = %s AND id <= %d", self::TYPE_LOG, (int) $cutoff ) ); } return $deleted; } /** @return array */ public static function logs( int $limit = 50 ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT status, payload, created_at FROM {$wpdb->prefix}mowl_events WHERE type = %s ORDER BY id DESC LIMIT %d", self::TYPE_LOG, $limit ) ); $out = array(); foreach ( $rows as $row ) { $payload = json_decode( (string) $row->payload, true ); $out[] = array( 'at' => gmdate( 'c', strtotime( (string) $row->created_at ) ), 'level' => (string) $row->status, 'message' => (string) ( $payload['message'] ?? '' ), 'context' => is_array( $payload['context'] ?? null ) ? $payload['context'] : array(), ); } return $out; } public static function clear_logs(): void { global $wpdb; $wpdb->delete( self::table(), array( 'type' => self::TYPE_LOG ), array( '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla } private static function update_status( string $event_id, string $status, array $extra ): void { global $wpdb; $wpdb->update( self::table(), array_merge( array( 'status' => $status ), $extra ), array( 'event_id' => $event_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla } /** * Beszúrás ismert (determinisztikus) event_id-vel. Ha ilyen sor már van, * nem történik semmi — az INSERT IGNORE a unique event_id-n csúszik el, * hibaüzenet nélkül. * * @return bool igaz, ha új sor keletkezett. */ public static function enqueue_unique( string $event_id, string $type, string $email, array $payload ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla $inserted = $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, nem paraméterezhető "INSERT IGNORE INTO {$wpdb->prefix}mowl_events (event_id, type, order_id, email, payload, status, attempts, created_at) VALUES (%s, %s, NULL, %s, %s, %s, 0, %s)", $event_id, $type, $email, wp_json_encode( $payload, JSON_INVALID_UTF8_SUBSTITUTE ), self::STATUS_PENDING, current_time( 'mysql', true ) ) ); return 1 === (int) $inserted; } public static function has_event_id( string $event_id ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az érték prepare-relve return null !== $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}mowl_events WHERE event_id = %s LIMIT 1", $event_id ) ); } /** Hány el nem küldött (pending) sor van egy típusból — a backfill ezzel fogja vissza magát. */ public static function pending_count( string $type ): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az értékek prepare-relve return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}mowl_events WHERE type = %s AND status = %s", $type, self::STATUS_PENDING ) ); } /** * A cím még el nem küldött sorai — a személyesadat-exportálónak. * * @return array */ public static function pending_for_email( string $email ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az értékek prepare-relve return (array) $wpdb->get_results( $wpdb->prepare( "SELECT event_id, type, payload, created_at FROM {$wpdb->prefix}mowl_events WHERE email = %s AND status = %s AND type <> %s ORDER BY id ASC", $email, self::STATUS_PENDING, self::TYPE_LOG ) ); } /** A cím minden (nem napló) sorának törlése — a személyesadat-törlőnek. */ public static function delete_for_email( string $email ): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az értékek prepare-relve return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}mowl_events WHERE email = %s AND type <> %s", $email, self::TYPE_LOG ) ); } }