continue`,于是一行"只知道附件 ID" * 的媒体被静默丢掉。两个地方会踩到: * · 模板包(kit)里只能写附件引用,**写不出 URL** —— 包在导入之前 * 根本不知道买家站的域名是什么; * · 站点换域名之后,存下来的绝对 URL 全部失效,而 ID 不会。 * 存 ID 比存 URL 更对,所以这里补一条:url 为空就从 ID 现算。 */ if ( empty( $item['url'] ) && ! empty( $item['attachment_id'] ) ) { $derived = wp_get_attachment_url( absint( $item['attachment_id'] ) ); if ( $derived ) { $item['url'] = $derived; } } if ( empty( $item['url'] ) ) { continue; } $clean[] = array( 'type' => isset( $item['type'] ) && 'video' === $item['type'] ? 'video' : 'image', 'source' => isset( $item['source'] ) && 'remote' === $item['source'] ? 'remote' : 'upload', 'url' => esc_url_raw( $item['url'] ), 'thumb_url' => isset( $item['thumb_url'] ) ? esc_url_raw( $item['thumb_url'] ) : '', 'attachment_id' => isset( $item['attachment_id'] ) ? absint( $item['attachment_id'] ) : 0, ); } return $clean; } /** * Persists the media list. Empty input clears the meta row. * * @param int $post_id * @param array $items */ public static function save_media( $post_id, $items ) { if ( ! is_array( $items ) ) { $items = array(); } $sanitized = array(); foreach ( $items as $item ) { if ( ! is_array( $item ) || empty( $item['url'] ) ) { continue; } $url = esc_url_raw( $item['url'] ); if ( ! $url ) { continue; } $sanitized[] = array( 'type' => isset( $item['type'] ) && 'video' === $item['type'] ? 'video' : 'image', 'source' => isset( $item['source'] ) && 'remote' === $item['source'] ? 'remote' : 'upload', 'url' => $url, 'thumb_url' => isset( $item['thumb_url'] ) ? esc_url_raw( $item['thumb_url'] ) : '', 'attachment_id' => isset( $item['attachment_id'] ) ? absint( $item['attachment_id'] ) : 0, ); } if ( empty( $sanitized ) ) { delete_post_meta( $post_id, self::KEY_MEDIA ); } else { update_post_meta( $post_id, self::KEY_MEDIA, wp_json_encode( $sanitized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ); } } /* ---------------------------------------------------------------- * Price * ---------------------------------------------------------------- */ /** * Returns the price block as a normalized array. * * @param int $post_id * @return array { type, min, max, currency, text } */ public static function get_price( $post_id ) { $type = get_post_meta( $post_id, self::KEY_PRICE_TYPE, true ); if ( ! in_array( $type, array( 'range', 'text', 'hidden' ), true ) ) { $type = 'hidden'; } return array( 'type' => $type, 'min' => get_post_meta( $post_id, self::KEY_PRICE_MIN, true ), 'max' => get_post_meta( $post_id, self::KEY_PRICE_MAX, true ), 'currency' => get_post_meta( $post_id, self::KEY_PRICE_CURRENCY, true ), 'text' => get_post_meta( $post_id, self::KEY_PRICE_TEXT, true ), ); } /** * Persists the price block. Unused fields for the chosen mode * are cleared to keep the meta table tidy. * * @param int $post_id * @param array $data { type, min, max, currency, text } */ public static function save_price( $post_id, $data ) { $type = isset( $data['type'] ) && in_array( $data['type'], array( 'range', 'text', 'hidden' ), true ) ? $data['type'] : 'hidden'; update_post_meta( $post_id, self::KEY_PRICE_TYPE, $type ); if ( 'range' === $type ) { $min = isset( $data['min'] ) && '' !== $data['min'] ? (float) $data['min'] : ''; $max = isset( $data['max'] ) && '' !== $data['max'] ? (float) $data['max'] : ''; update_post_meta( $post_id, self::KEY_PRICE_MIN, $min ); update_post_meta( $post_id, self::KEY_PRICE_MAX, $max ); delete_post_meta( $post_id, self::KEY_PRICE_TEXT ); } elseif ( 'text' === $type ) { update_post_meta( $post_id, self::KEY_PRICE_TEXT, isset( $data['text'] ) ? sanitize_text_field( $data['text'] ) : '' ); delete_post_meta( $post_id, self::KEY_PRICE_MIN ); delete_post_meta( $post_id, self::KEY_PRICE_MAX ); } else { delete_post_meta( $post_id, self::KEY_PRICE_MIN ); delete_post_meta( $post_id, self::KEY_PRICE_MAX ); delete_post_meta( $post_id, self::KEY_PRICE_TEXT ); } if ( isset( $data['currency'] ) && '' !== $data['currency'] ) { update_post_meta( $post_id, self::KEY_PRICE_CURRENCY, sanitize_text_field( $data['currency'] ) ); } else { delete_post_meta( $post_id, self::KEY_PRICE_CURRENCY ); } } /* ---------------------------------------------------------------- * Attributes (with shadow meta for filtering) * ---------------------------------------------------------------- */ /** * Returns the attributes list. * * @param int $post_id * @return array of [ { key, value } ] */ public static function get_attributes( $post_id ) { $raw = get_post_meta( $post_id, self::KEY_ATTRIBUTES, true ); if ( empty( $raw ) ) { return array(); } $decoded = is_array( $raw ) ? $raw : json_decode( $raw, true ); if ( ! is_array( $decoded ) ) { return array(); } $clean = array(); foreach ( $decoded as $row ) { if ( ! is_array( $row ) ) { continue; } $k = isset( $row['key'] ) ? trim( (string) $row['key'] ) : ''; $v = isset( $row['value'] ) ? trim( (string) $row['value'] ) : ''; if ( '' === $k && '' === $v ) { continue; } $clean[] = array( 'key' => $k, 'value' => $v ); } return $clean; } /** * Persists attributes AND rewrites shadow meta rows for any keys * configured as filterable. Always wipes the previous shadow set * for this post first to avoid stale entries when keys are * renamed or removed. * * @param int $post_id * @param array $rows of [ { key, value } ] */ public static function save_attributes( $post_id, $rows ) { if ( ! is_array( $rows ) ) { $rows = array(); } $sanitized = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $k = isset( $row['key'] ) ? sanitize_text_field( $row['key'] ) : ''; $v = isset( $row['value'] ) ? sanitize_text_field( $row['value'] ) : ''; if ( '' === $k && '' === $v ) { continue; } $sanitized[] = array( 'key' => $k, 'value' => $v ); } if ( empty( $sanitized ) ) { delete_post_meta( $post_id, self::KEY_ATTRIBUTES ); } else { update_post_meta( $post_id, self::KEY_ATTRIBUTES, wp_json_encode( $sanitized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ); } self::rebuild_attribute_shadows( $post_id, $sanitized ); } /** * Rebuilds the flat shadow meta rows used for fast filtering on * archive queries. Called on save and also exposed publicly so * a future bulk re-index command can rebuild without saving. * * @param int $post_id * @param array $rows Pre-sanitized rows. */ public static function rebuild_attribute_shadows( $post_id, $rows ) { global $wpdb; $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key LIKE %s", $post_id, $wpdb->esc_like( self::KEY_ATTR_SHADOW_PREFIX ) . '%' ) ); $settings = AumNexCart_Settings::get_all(); $filterable = isset( $settings['filterable_attributes'] ) && is_array( $settings['filterable_attributes'] ) ? array_map( 'strtolower', $settings['filterable_attributes'] ) : array(); if ( empty( $filterable ) || empty( $rows ) ) { return; } foreach ( $rows as $row ) { $slug = self::attribute_key_to_slug( $row['key'] ); if ( '' === $slug ) { continue; } if ( ! in_array( strtolower( $row['key'] ), $filterable, true ) ) { continue; } add_post_meta( $post_id, self::KEY_ATTR_SHADOW_PREFIX . $slug, $row['value'] ); } } /** * Normalizes a user-entered attribute label into a URL-safe slug * suitable for use as a meta key suffix. Returns '' if nothing * usable remains. * * @param string $key * @return string */ public static function attribute_key_to_slug( $key ) { $slug = sanitize_title( $key ); return $slug ? $slug : ''; } /* ---------------------------------------------------------------- * Contact actions * ---------------------------------------------------------------- */ const ALLOWED_CONTACT_TYPES = array( 'email', 'whatsapp', 'custom' ); /** * Returns contact actions normalized into [ { type, label, value, href } ]. * The href is rendered server-side so templates don't need to know * the per-type formatting (mailto:, wa.me, etc). * * @param int $post_id * @return array */ public static function get_contact( $post_id ) { $raw = get_post_meta( $post_id, self::KEY_CONTACT, true ); if ( empty( $raw ) ) { return array(); } $decoded = is_array( $raw ) ? $raw : json_decode( $raw, true ); if ( ! is_array( $decoded ) ) { return array(); } $clean = array(); foreach ( $decoded as $row ) { $item = self::normalize_contact_row( $row ); if ( $item ) { $clean[] = $item; } } return $clean; } /** * Persists contact actions, applying type-aware sanitization * before storing. * * @param int $post_id * @param array $rows */ public static function save_contact( $post_id, $rows ) { if ( ! is_array( $rows ) ) { $rows = array(); } $sanitized = array(); foreach ( $rows as $row ) { $item = self::normalize_contact_row( $row, true ); if ( $item ) { unset( $item['href'] ); $sanitized[] = $item; } } if ( empty( $sanitized ) ) { delete_post_meta( $post_id, self::KEY_CONTACT ); } else { update_post_meta( $post_id, self::KEY_CONTACT, wp_json_encode( $sanitized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ); } } /** * Validates and normalizes one contact row. Returns null when the * row is unusable. The $strict flag enforces input-time * sanitization (used on save); when false (read path) we still * sanitize but tolerate slightly looser data. * * @param array $row * @param bool $strict * @return array|null { type, label, value, href } */ public static function normalize_contact_row( $row, $strict = false ) { if ( ! is_array( $row ) ) { return null; } $type = isset( $row['type'] ) ? strtolower( trim( (string) $row['type'] ) ) : ''; $label = isset( $row['label'] ) ? sanitize_text_field( $row['label'] ) : ''; $value = isset( $row['value'] ) ? trim( (string) $row['value'] ) : ''; if ( ! in_array( $type, self::ALLOWED_CONTACT_TYPES, true ) ) { return null; } if ( '' === $value ) { return null; } $href = ''; if ( 'email' === $type ) { $email = sanitize_email( $value ); if ( ! is_email( $email ) ) { return null; } $value = $email; $href = 'mailto:' . rawurlencode( $email ); } elseif ( 'whatsapp' === $type ) { $digits = preg_replace( '/[^0-9]/', '', $value ); if ( strlen( $digits ) < 6 ) { return null; } $value = $digits; $href = 'https://wa.me/' . $digits; } else { // custom $url = esc_url_raw( $value, array( 'http', 'https', 'mailto', 'tel' ) ); if ( ! $url ) { return null; } $lower = strtolower( $url ); if ( strpos( $lower, 'javascript:' ) === 0 || strpos( $lower, 'data:' ) === 0 ) { return null; } $value = $url; $href = $url; } if ( '' === $label ) { $label = ucfirst( $type ); } return array( 'type' => $type, 'label' => $label, 'value' => $value, 'href' => $href, ); } }