*/ private const PROVIDERS = array( 'amazon', 'rakuten', 'yahoo_shopping', 'all' ); /** * Render-cache `kind` whitelist (§22.2). * * @var list */ private const KINDS = array( 'single', 'grid' ); /** * Sort-key whitelist used in `productsHash` per §22.2. * * @var list */ private const SORT_KEYS = array( 'manual', 'price_asc', 'price_desc', 'recent' ); /** * `configHash` attribute whitelist — common keys (apply to * both `single` and `grid`). Mirrors * `rendering-and-template-spec.md` §22.2 "共通". * * @var list */ private const RELEVANT_ATTR_KEYS_COMMON = array( 'template', 'showPrice', 'showBrand', 'showDescription', 'showProviderBadge', 'showDisclaimer', 'openInNewTab', 'nofollow', 'sponsored', 'trackingEnabled', 'buttonLabels', 'align', 'templateVariant', 'buttonLayout', 'buttonShape', 'buttonTone', 'colorScheme', 'customButtons', 'customCssClass', 'microcopyText', 'microcopyStartAt', 'microcopyEndAt', 'activeMicrocopyLabel', 'abTestingEnabled', 'abButtonLabelVariant', 'abMicrocopyVariant', ); /** * `configHash` attribute whitelist — grid-only keys. * Mirrors `rendering-and-template-spec.md` §22.2 "grid 固有". * * @var list */ private const RELEVANT_ATTR_KEYS_GRID = array( 'columns', 'gap', 'sortMode', ); /** * Public no-arg constructor. The factory is dependency-free * by design; it is registered as an instance so DI wiring * can substitute it (e.g. for keyed-fixture tests) without * reaching into static state. */ public function __construct() { } /** * Build a provider-search cache key: * `affilun:provider:{provider}:search:{queryHash}:v1`. * * @param string $provider One of {@see self::PROVIDERS}. * @param string $queryHash 64-char lowercase hex (sha256). * * @throws InvalidArgumentException When inputs are invalid. */ public function searchKey( string $provider, string $queryHash ): string { $provider = $this->assertProvider( $provider ); $this->assertSha256Hex( $queryHash, 'queryHash' ); return 'affilun:provider:' . $provider . ':search:' . $queryHash . ':' . self::VERSION; } /** * Convenience wrapper for the cross-provider search channel * (`provider = "all"`). * * @param string $queryHash 64-char lowercase hex (sha256). * * @throws InvalidArgumentException When inputs are invalid. */ public function crossProviderSearchKey( string $queryHash ): string { return $this->searchKey( 'all', $queryHash ); } /** * Build a single-item lookup cache key: * `affilun:provider:{provider}:item:{providerItemHash}:v1`. * * Provider item ids are case-sensitive and may contain characters that * conflict with the colon-separated cache-key form (Rakuten itemCode, * for example, is `shop:item`). The key therefore stores a deterministic * sha256 digest of the trimmed id rather than the raw id. * * @param string $provider One of {@see self::PROVIDERS}. * @param string $providerItemId Sanitized item id token. * * @throws InvalidArgumentException When inputs are invalid. */ public function lookupKey( string $provider, string $providerItemId ): string { $provider = $this->assertProvider( $provider ); $providerItemId = $this->assertProviderItemId( $providerItemId ); return 'affilun:provider:' . $provider . ':item:' . hash( 'sha256', $providerItemId ) . ':' . self::VERSION; } /** * Build a provider health cache key: * `affilun:provider:{provider}:health:v1`. * * @param string $provider One of {@see self::PROVIDERS}. * * @throws InvalidArgumentException When inputs are invalid. */ public function providerHealthKey( string $provider ): string { $provider = $this->assertProvider( $provider ); return 'affilun:provider:' . $provider . ':health:' . self::VERSION; } /** * Build an embed-backed render-cache key: * `affilun:render:{kind}:embed:{embedId}:{template}:{configHash}:v2`. * * @param string $kind `single` or `grid`. * @param int $embedId Positive embed id. * @param string $template Non-empty trimmed template key. * @param string $configHash 64-char lowercase hex (sha256). * * @throws InvalidArgumentException When inputs are invalid. */ public function renderEmbedKey( string $kind, int $embedId, string $template, string $configHash ): string { $kind = $this->assertKind( $kind ); $template = $this->assertTemplate( $template ); $this->assertSha256Hex( $configHash, 'configHash' ); if ( $embedId < 1 ) { $message = sprintf( 'CacheKeyFactory: embedId must be >= 1; got %d.', $embedId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return 'affilun:render:' . $kind . ':embed:' . $embedId . ':' . $template . ':' . $configHash . ':' . self::RENDER_VERSION; } /** * Build an ad-hoc render-cache key: * `affilun:render:{kind}:adhoc:{productsHash}:{template}:{configHash}:v2`. * * @param string $kind `single` or `grid`. * @param string $productsHash 64-char lowercase hex (sha256). * @param string $template Non-empty trimmed template key. * @param string $configHash 64-char lowercase hex (sha256). * * @throws InvalidArgumentException When inputs are invalid. */ public function renderAdhocKey( string $kind, string $productsHash, string $template, string $configHash ): string { $kind = $this->assertKind( $kind ); $template = $this->assertTemplate( $template ); $this->assertSha256Hex( $productsHash, 'productsHash' ); $this->assertSha256Hex( $configHash, 'configHash' ); return 'affilun:render:' . $kind . ':adhoc:' . $productsHash . ':' . $template . ':' . $configHash . ':' . self::RENDER_VERSION; } /** * Build the §10.1 / §6.3 preview cache key: * `affilun:render:preview:{userId}:{kind}:{idsToken}:{template}:v1`. * * The `idsToken` parameter is intentionally untyped beyond * "non-empty trimmed string": callers may pass either the * raw comma-joined product-id list (the existing form * produced by `PreviewProductCardUseCase::composeCacheKey()`) * or a hash of it. The byte-level form is preserved so the * Phase-6 preview cache continues to hit the same key. * * `$userId >= 0` is permitted (0 keeps the surface * conservative; the higher-level use case still enforces * `>= 1` for authenticated previews). * * @param int $userId Non-negative scoping id. * @param string $kind `single` or `grid`. * @param string $idsToken Comma-joined ids or a hash thereof. * @param string $template Non-empty trimmed template key. * * @throws InvalidArgumentException When inputs are invalid. */ public function previewKey( int $userId, string $kind, string $idsToken, string $template ): string { if ( $userId < 0 ) { $message = sprintf( 'CacheKeyFactory: userId must be >= 0; got %d.', $userId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $kind = $this->assertKind( $kind ); $template = $this->assertTemplate( $template ); if ( trim( $idsToken ) === '' ) { $message = 'CacheKeyFactory: idsToken must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return 'affilun:render:preview:' . $userId . ':' . $kind . ':' . $idsToken . ':' . $template . ':' . self::VERSION; } /** * Build a template-resolution cache key: * `affilun:template:{templateKey}:{themeHash}:v1`. * * @param string $templateKey Non-empty trimmed template key. * @param string $themeHash Non-empty trimmed theme hash. * * @throws InvalidArgumentException When inputs are invalid. */ public function templateKey( string $templateKey, string $themeHash ): string { if ( trim( $templateKey ) === '' ) { $message = 'CacheKeyFactory: templateKey must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( trim( $themeHash ) === '' ) { $message = 'CacheKeyFactory: themeHash must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return 'affilun:template:' . $templateKey . ':' . $themeHash . ':' . self::VERSION; } /** * Compute `productsHash` per §22.2: * `sha256( canonical_join(productIds) + "|" + sortKey )`. * * `productIds` is normalized (each entry trimmed, rejected * if empty), sorted ascending lexicographically, and * comma-joined. `sortKey` must be one of * {@see self::SORT_KEYS}. * * @param list $productIds Non-empty list of ids. * @param string $sortKey Validated sort token. * * @throws InvalidArgumentException When inputs are invalid. */ public function productsHash( array $productIds, string $sortKey ): string { if ( $productIds === array() ) { $message = 'CacheKeyFactory: productIds must be a non-empty list.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! in_array( $sortKey, self::SORT_KEYS, true ) ) { $message = sprintf( 'CacheKeyFactory: sortKey must be one of [%s]; got "%s".', implode( ', ', self::SORT_KEYS ), $sortKey ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized = array(); foreach ( $productIds as $index => $value ) { if ( ! is_string( $value ) ) { $message = sprintf( 'CacheKeyFactory: productIds[%d] must be a string; got %s.', (int) $index, gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = 'CacheKeyFactory: productIds entries must be non-empty trimmed strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[] = $trimmed; } sort( $normalized, SORT_STRING ); $canonical = implode( ',', $normalized ); return hash( 'sha256', $canonical . '|' . $sortKey ); } /** * Compute `configHash` per §22.2: sha256 of a canonical * JSON serialization of the (recursively key-sorted) input * array. * * The factory does not enforce that `$relevantBlockAttrs` * only contains whitelisted keys — that is the caller's * responsibility, because some callers intentionally hash * the canonical superset for forward compatibility. Use * {@see self::filterRelevantAttrs()} to drop disallowed * keys before hashing when strict adherence is desired. * * @param array $relevantBlockAttrs Attribute * map. * * @throws InvalidArgumentException When JSON encoding fails. */ public function configHash( array $relevantBlockAttrs ): string { return $this->canonicalJsonSha256( $relevantBlockAttrs, 'configHash' ); } /** * Compute the `queryHash` used by provider-search keys: * sha256 of a canonical JSON serialization of the * descriptor. * * @param array $queryDescriptor Normalized * descriptor. * * @throws InvalidArgumentException When JSON encoding fails. */ public function queryHash( array $queryDescriptor ): string { return $this->canonicalJsonSha256( $queryDescriptor, 'queryHash' ); } /** * Drop attribute keys that are not part of the §22.2 * whitelist for the given `kind`. * * Filtering is intentionally opt-in (see * {@see self::configHash()}): some callers want to hash the * canonical superset for forward compatibility with * unrecognized attributes added in later releases. Callers * that want strict §22.2 adherence should run their * attribute map through this filter before hashing. * * @param array $attrs Attribute map. * @param string $kind `single` or `grid`. * * @return array * * @throws InvalidArgumentException When `$kind` is invalid. */ public static function filterRelevantAttrs( array $attrs, string $kind ): array { if ( ! in_array( $kind, self::KINDS, true ) ) { $message = sprintf( 'CacheKeyFactory: kind must be one of [%s]; got "%s".', implode( ', ', self::KINDS ), $kind ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $allowed = self::RELEVANT_ATTR_KEYS_COMMON; if ( $kind === 'grid' ) { $allowed = array_merge( $allowed, self::RELEVANT_ATTR_KEYS_GRID ); } $filtered = array(); foreach ( $attrs as $key => $value ) { if ( is_string( $key ) && in_array( $key, $allowed, true ) ) { $filtered[ $key ] = $value; } } return $filtered; } /** * Canonical-JSON sha256 helper used by both * {@see self::configHash()} and {@see self::queryHash()}. * * @param array|array $payload Input payload. * @param string $context Diagnostic label. * * @throws InvalidArgumentException When JSON encoding fails. */ private function canonicalJsonSha256( array $payload, string $context ): string { $canonical = $this->canonicalize( $payload ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Canonical bytes required; wp_json_encode adds slash-escaping that breaks determinism across hosts. $json = json_encode( $canonical, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ( $json === false ) { $message = sprintf( 'CacheKeyFactory: %s payload could not be JSON-encoded canonically.', $context ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return hash( 'sha256', $json ); } /** * Recursively sort associative-array keys to make JSON * encoding canonical. List arrays (zero-based contiguous * integer keys) preserve their order, since list ordering * is itself semantic input. * * @param mixed $value Arbitrary scalar / array tree. * * @return mixed */ private function canonicalize( $value ) { if ( ! is_array( $value ) ) { return $value; } $isList = array_keys( $value ) === range( 0, count( $value ) - 1 ); if ( $isList ) { $out = array(); foreach ( $value as $entry ) { $out[] = $this->canonicalize( $entry ); } return $out; } ksort( $value, SORT_STRING ); $out = array(); foreach ( $value as $key => $entry ) { $out[ (string) $key ] = $this->canonicalize( $entry ); } return $out; } /** * Validate a provider token and return the lowercased form. * * @param string $provider Raw provider token. * * @throws InvalidArgumentException When the token is unknown. */ private function assertProvider( string $provider ): string { $normalized = strtolower( trim( $provider ) ); if ( ! in_array( $normalized, self::PROVIDERS, true ) ) { $message = sprintf( 'CacheKeyFactory: provider must be one of [%s]; got "%s".', implode( ', ', self::PROVIDERS ), $provider ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $normalized; } /** * Validate a `kind` token. * * @param string $kind Raw kind token. * * @throws InvalidArgumentException When the token is unknown. */ private function assertKind( string $kind ): string { if ( ! in_array( $kind, self::KINDS, true ) ) { $message = sprintf( 'CacheKeyFactory: kind must be one of [%s]; got "%s".', implode( ', ', self::KINDS ), $kind ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $kind; } /** * Validate a non-empty trimmed template key and return it * unchanged (case preserved). * * @param string $template Raw template key. * * @throws InvalidArgumentException When empty after trim. */ private function assertTemplate( string $template ): string { if ( trim( $template ) === '' ) { $message = 'CacheKeyFactory: template must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $template; } /** * Sanitize a provider item id: trim, reject empty, reject whitespace and * control characters. Delimiters such as `:` are accepted because callers * hash the returned id before inserting it into a cache key. * * Case is preserved because some provider id schemes (e.g. * Amazon ASINs) are case-sensitive. * * @param string $providerItemId Raw item id. * * @throws InvalidArgumentException When the id is invalid. */ private function assertProviderItemId( string $providerItemId ): string { $trimmed = trim( $providerItemId ); if ( $trimmed === '' ) { $message = 'CacheKeyFactory: providerItemId must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( preg_match( '/[\s\x00-\x1f\x7f]/', $trimmed ) === 1 ) { $message = sprintf( 'CacheKeyFactory: providerItemId must not contain whitespace or control characters; got "%s".', $providerItemId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate that a string is a 64-char lowercase hex digest * (sha256 output shape). * * @param string $hash Candidate digest. * @param string $label Diagnostic label for error messages. * * @throws InvalidArgumentException When the shape is wrong. */ private function assertSha256Hex( string $hash, string $label ): void { if ( preg_match( '/^[0-9a-f]{64}$/', $hash ) !== 1 ) { $message = sprintf( 'CacheKeyFactory: %s must be a 64-char lowercase hex sha256 digest; got "%s".', $label, $hash ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } }