ensureConfigDefaults(); $this->ensureHelpArticlesTable(); $this->seedHelpArticles(); $this->ensurePolicyHelpArticles(); self::$bootstrapped = true; } public function getHomeConfig(): array { $this->bootstrapDefaults(); $defaults = $this->defaultHomeConfig(); $configMap = Db::name('system_configs') ->where('config_group', self::HOME_GROUP) ->column('config_value', 'config_key'); return [ 'banners' => $this->decodeJsonConfig($configMap['banners_json'] ?? '', $defaults['banners']), 'page_visuals' => $this->normalizeObjectConfig( $this->decodeJsonObjectConfig($configMap['page_visuals_json'] ?? '', $defaults['page_visuals']), ['order_background_image_url', 'report_background_image_url'], $defaults['page_visuals'] ), 'service_entries' => $this->decodeJsonConfig($configMap['service_entries_json'] ?? '', $defaults['service_entries']), 'category_visuals' => $this->decodeJsonConfig($configMap['category_visuals_json'] ?? '', $defaults['category_visuals']), 'quick_entries' => $this->decodeJsonConfig($configMap['quick_entries_json'] ?? '', $defaults['quick_entries']), 'trust_metrics' => $this->decodeJsonConfig($configMap['trust_metrics_json'] ?? '', $defaults['trust_metrics']), 'trust_points' => $this->decodeJsonConfig($configMap['trust_points_json'] ?? '', $defaults['trust_points']), 'faqs' => $this->decodeJsonConfig($configMap['faqs_json'] ?? '', $defaults['faqs']), ]; } public function getPolicyConfig(): array { $this->bootstrapDefaults(); $defaults = $this->defaultPolicyConfig(); $configMap = Db::name('system_configs') ->where('config_group', self::POLICY_GROUP) ->column('config_value', 'config_key'); return [ 'legal_entries' => $this->hydratePolicyItems( $this->decodeJsonConfig($configMap['legal_entries_json'] ?? '', $defaults['legal_entries']) ), 'appraisal_agreements' => $this->hydratePolicyItems( $this->decodeJsonConfig($configMap['appraisal_agreements_json'] ?? '', $defaults['appraisal_agreements']) ), ]; } public function getMetaConfig(): array { $this->bootstrapDefaults(); $defaults = $this->defaultMetaConfig(); $configMap = Db::name('system_configs') ->where('config_group', self::META_GROUP) ->column('config_value', 'config_key'); return [ 'help_categories' => $this->decodeJsonConfig($configMap['help_categories_json'] ?? '', $defaults['help_categories']), 'report_risk_defaults' => $this->decodeJsonConfig($configMap['report_risk_defaults_json'] ?? '', $defaults['report_risk_defaults']), 'ticket_types' => $this->decodeJsonConfig($configMap['ticket_types_json'] ?? '', $defaults['ticket_types']), 'ticket_statuses' => $this->decodeJsonConfig($configMap['ticket_statuses_json'] ?? '', $defaults['ticket_statuses']), 'message_events' => $this->decodeJsonConfig($configMap['message_events_json'] ?? '', $defaults['message_events']), 'message_page_copy' => $this->decodeJsonObjectConfig($configMap['message_page_copy_json'] ?? '', $defaults['message_page_copy']), ]; } public function saveHomeConfig(array $payload): void { $this->bootstrapDefaults(); $defaults = $this->defaultHomeConfig(); $existing = $this->getHomeConfig(); $categoryVisuals = array_key_exists('category_visuals', $payload) ? $payload['category_visuals'] : $existing['category_visuals']; $normalized = [ 'banners_json' => json_encode($this->normalizeArrayItems($payload['banners'] ?? [], ['title', 'subtitle', 'description', 'background_image_url'], $defaults['banners']), JSON_UNESCAPED_UNICODE), 'page_visuals_json' => json_encode($this->normalizeObjectConfig($payload['page_visuals'] ?? [], ['order_background_image_url', 'report_background_image_url'], $defaults['page_visuals']), JSON_UNESCAPED_UNICODE), 'service_entries_json' => json_encode($this->normalizeArrayItems($payload['service_entries'] ?? [], ['service_provider', 'title', 'tag', 'description', 'meta'], $defaults['service_entries']), JSON_UNESCAPED_UNICODE), 'category_visuals_json' => json_encode($this->normalizeArrayItems($categoryVisuals, ['category_name', 'category_code', 'image_url'], $defaults['category_visuals']), JSON_UNESCAPED_UNICODE), 'quick_entries_json' => json_encode($this->normalizeArrayItems($payload['quick_entries'] ?? [], ['code', 'title', 'desc'], $defaults['quick_entries']), JSON_UNESCAPED_UNICODE), 'trust_metrics_json' => json_encode($this->normalizeArrayItems($payload['trust_metrics'] ?? [], ['value', 'label'], $defaults['trust_metrics']), JSON_UNESCAPED_UNICODE), 'trust_points_json' => json_encode($this->normalizeArrayItems($payload['trust_points'] ?? [], ['title', 'desc'], $defaults['trust_points']), JSON_UNESCAPED_UNICODE), 'faqs_json' => json_encode($this->normalizeStringList($payload['faqs'] ?? [], $defaults['faqs']), JSON_UNESCAPED_UNICODE), ]; $now = date('Y-m-d H:i:s'); foreach ($normalized as $configKey => $configValue) { $this->upsertSystemConfig(self::HOME_GROUP, $configKey, $configValue, $now); } } public function savePolicyConfig(array $payload): void { $this->bootstrapDefaults(); $defaults = $this->defaultPolicyConfig(); $normalized = [ 'legal_entries_json' => json_encode($this->normalizePolicyItems($payload['legal_entries'] ?? [], $defaults['legal_entries']), JSON_UNESCAPED_UNICODE), 'appraisal_agreements_json' => json_encode($this->normalizePolicyItems($payload['appraisal_agreements'] ?? [], $defaults['appraisal_agreements']), JSON_UNESCAPED_UNICODE), ]; $now = date('Y-m-d H:i:s'); foreach ($normalized as $configKey => $configValue) { $this->upsertSystemConfig(self::POLICY_GROUP, $configKey, $configValue, $now); } } public function saveMetaConfig(array $payload): void { $this->bootstrapDefaults(); $defaults = $this->defaultMetaConfig(); $normalized = [ 'help_categories_json' => json_encode($this->normalizeArrayItems($payload['help_categories'] ?? [], ['code', 'title', 'desc'], $defaults['help_categories']), JSON_UNESCAPED_UNICODE), 'report_risk_defaults_json' => json_encode($this->normalizeArrayItems($payload['report_risk_defaults'] ?? [], ['report_type', 'title', 'text'], $defaults['report_risk_defaults']), JSON_UNESCAPED_UNICODE), 'ticket_types_json' => json_encode($this->normalizeArrayItems($payload['ticket_types'] ?? [], ['code', 'title', 'hint', 'quick_desc'], $defaults['ticket_types']), JSON_UNESCAPED_UNICODE), 'ticket_statuses_json' => json_encode($this->normalizeArrayItems($payload['ticket_statuses'] ?? [], ['code', 'title', 'desc'], $defaults['ticket_statuses']), JSON_UNESCAPED_UNICODE), 'message_events_json' => json_encode($this->normalizeArrayItems($payload['message_events'] ?? [], ['event_code', 'title', 'desc'], $defaults['message_events']), JSON_UNESCAPED_UNICODE), 'message_page_copy_json' => json_encode($this->normalizeObjectConfig($payload['message_page_copy'] ?? [], ['title', 'desc'], $defaults['message_page_copy']), JSON_UNESCAPED_UNICODE), ]; $now = date('Y-m-d H:i:s'); foreach ($normalized as $configKey => $configValue) { $this->upsertSystemConfig(self::META_GROUP, $configKey, $configValue, $now); } } public function getHelpArticles(bool $enabledOnly = false): array { $this->bootstrapDefaults(); $query = Db::name(self::HELP_TABLE)->order('sort_order', 'asc')->order('id', 'asc'); if ($enabledOnly) { $query->where('is_enabled', 1); } return array_map(function (array $item) { $contentBlocks = $this->decodeJsonConfig($item['content_blocks_json'] ?? '', []); $contentHtml = trim((string)($item['content_html'] ?? '')); if ($contentHtml === '') { $contentHtml = $this->contentBlocksToHtml($contentBlocks); } else { $contentHtml = $this->sanitizeHelpArticleHtml($contentHtml); } if (!$contentBlocks && $contentHtml !== '') { $contentBlocks = $this->contentBlocksFromHtml($contentHtml); } return [ 'id' => (int)$item['id'], 'category' => (string)$item['category'], 'category_text' => $this->categoryText((string)$item['category']), 'title' => (string)$item['title'], 'summary' => (string)$item['summary'], 'keywords' => $this->decodeJsonConfig($item['keywords_json'] ?? '', []), 'updated_at' => (string)$item['updated_at'], 'is_recommended' => (bool)$item['is_recommended'], 'is_enabled' => (bool)$item['is_enabled'], 'sort_order' => (int)$item['sort_order'], 'content_html' => $contentHtml, 'content_blocks' => $contentBlocks, ]; }, $query->select()->toArray()); } public function getHelpCategories(): array { $meta = $this->getMetaConfig(); return $meta['help_categories']; } public function getTicketTypes(): array { $meta = $this->getMetaConfig(); return $meta['ticket_types']; } public function getTicketStatuses(): array { $meta = $this->getMetaConfig(); return $meta['ticket_statuses']; } public function getMessageEvents(): array { $meta = $this->getMetaConfig(); return $meta['message_events']; } public function getMessagePageCopy(): array { $meta = $this->getMetaConfig(); return $meta['message_page_copy']; } public function getHelpArticle(int $id): ?array { $items = $this->getHelpArticles(true); foreach ($items as $item) { if ((int)$item['id'] === $id) { return $item; } } return null; } public function saveHelpArticle(array $payload): int { $this->bootstrapDefaults(); $id = (int)($payload['id'] ?? 0); $category = trim((string)($payload['category'] ?? 'service')); $title = trim((string)($payload['title'] ?? '')); $summary = trim((string)($payload['summary'] ?? '')); $keywords = $this->normalizeStringList($payload['keywords'] ?? [], []); $contentHtml = $this->sanitizeHelpArticleHtml((string)($payload['content_html'] ?? '')); $contentBlocks = $this->normalizeStringList($payload['content_blocks'] ?? [], []); $isRecommended = !empty($payload['is_recommended']) ? 1 : 0; $isEnabled = array_key_exists('is_enabled', $payload) ? (!empty($payload['is_enabled']) ? 1 : 0) : 1; $sortOrder = (int)($payload['sort_order'] ?? 0); if ($contentHtml !== '') { $contentBlocks = $this->contentBlocksFromHtml($contentHtml); } if ($contentHtml === '' && $contentBlocks) { $contentHtml = $this->contentBlocksToHtml($contentBlocks); } if ($title === '' || $summary === '') { throw new \RuntimeException('文章标题和摘要不能为空'); } if (!$contentBlocks) { throw new \RuntimeException('请至少填写一段文章正文'); } if (!in_array($category, ['service', 'report', 'shipping', 'support'], true)) { throw new \RuntimeException('文章分类不合法'); } $now = date('Y-m-d H:i:s'); $data = [ 'category' => $category, 'title' => $title, 'summary' => $summary, 'keywords_json' => json_encode($keywords, JSON_UNESCAPED_UNICODE), 'content_html' => $contentHtml, 'content_blocks_json' => json_encode($contentBlocks, JSON_UNESCAPED_UNICODE), 'is_recommended' => $isRecommended, 'is_enabled' => $isEnabled, 'sort_order' => $sortOrder, 'updated_at' => $now, ]; if ($id > 0) { $exists = Db::name(self::HELP_TABLE)->where('id', $id)->find(); if (!$exists) { throw new \RuntimeException('帮助文章不存在'); } Db::name(self::HELP_TABLE)->where('id', $id)->update($data); return $id; } $data['created_at'] = $now; return (int)Db::name(self::HELP_TABLE)->insertGetId($data); } public function deleteHelpArticle(int $id): void { $this->bootstrapDefaults(); if ($id <= 0) { throw new \RuntimeException('文章 ID 不能为空'); } $exists = Db::name(self::HELP_TABLE)->where('id', $id)->find(); if (!$exists) { throw new \RuntimeException('帮助文章不存在'); } $reference = $this->findPolicyReferenceByArticleId($id); if ($reference !== null) { throw new \RuntimeException(sprintf('该文章已被“%s”引用,请先在内容中心的协议与说明中解绑', $reference)); } Db::name(self::HELP_TABLE)->where('id', $id)->delete(); } public function categoryText(string $category): string { foreach ($this->getHelpCategories() as $item) { if (($item['code'] ?? '') === $category) { return (string)($item['title'] ?? $category); } } return $category; } public function getReportRiskNotice(string $reportType): string { $meta = $this->getMetaConfig(); foreach ($meta['report_risk_defaults'] as $item) { if (($item['report_type'] ?? '') === $reportType) { return (string)($item['text'] ?? ''); } } return ''; } public function ticketTypeText(string $type): string { foreach ($this->getTicketTypes() as $item) { if (($item['code'] ?? '') === $type) { return (string)($item['title'] ?? $type); } } return $type; } public function ticketStatusText(string $status): string { foreach ($this->getTicketStatuses() as $item) { if (($item['code'] ?? '') === $status) { return (string)($item['title'] ?? $status); } } return $status; } private function ensureConfigDefaults(): void { $homeDefaults = $this->defaultHomeConfig(); $defaults = [ self::HOME_GROUP => [ 'banners_json' => json_encode($homeDefaults['banners'], JSON_UNESCAPED_UNICODE), 'page_visuals_json' => json_encode($homeDefaults['page_visuals'], JSON_UNESCAPED_UNICODE), 'service_entries_json' => json_encode($homeDefaults['service_entries'], JSON_UNESCAPED_UNICODE), 'category_visuals_json' => json_encode($homeDefaults['category_visuals'], JSON_UNESCAPED_UNICODE), 'quick_entries_json' => json_encode($homeDefaults['quick_entries'], JSON_UNESCAPED_UNICODE), 'trust_metrics_json' => json_encode($homeDefaults['trust_metrics'], JSON_UNESCAPED_UNICODE), 'trust_points_json' => json_encode($homeDefaults['trust_points'], JSON_UNESCAPED_UNICODE), 'faqs_json' => json_encode($homeDefaults['faqs'], JSON_UNESCAPED_UNICODE), ], self::POLICY_GROUP => [ 'legal_entries_json' => json_encode($this->defaultPolicyConfig()['legal_entries'], JSON_UNESCAPED_UNICODE), 'appraisal_agreements_json' => json_encode($this->defaultPolicyConfig()['appraisal_agreements'], JSON_UNESCAPED_UNICODE), ], self::META_GROUP => [ 'help_categories_json' => json_encode($this->defaultMetaConfig()['help_categories'], JSON_UNESCAPED_UNICODE), 'report_risk_defaults_json' => json_encode($this->defaultMetaConfig()['report_risk_defaults'], JSON_UNESCAPED_UNICODE), 'ticket_types_json' => json_encode($this->defaultMetaConfig()['ticket_types'], JSON_UNESCAPED_UNICODE), 'ticket_statuses_json' => json_encode($this->defaultMetaConfig()['ticket_statuses'], JSON_UNESCAPED_UNICODE), 'message_events_json' => json_encode($this->defaultMetaConfig()['message_events'], JSON_UNESCAPED_UNICODE), 'message_page_copy_json' => json_encode($this->defaultMetaConfig()['message_page_copy'], JSON_UNESCAPED_UNICODE), ], ]; $groupCodes = array_keys($defaults); $existingRows = Db::name('system_configs') ->field(['config_group', 'config_key']) ->whereIn('config_group', $groupCodes) ->select() ->toArray(); $existingMap = []; foreach ($existingRows as $item) { $existingMap[($item['config_group'] ?? '') . '.' . ($item['config_key'] ?? '')] = true; } $now = date('Y-m-d H:i:s'); $insertRows = []; foreach ($defaults as $groupCode => $items) { foreach ($items as $configKey => $configValue) { $mapKey = $groupCode . '.' . $configKey; if (isset($existingMap[$mapKey])) { continue; } $insertRows[] = [ 'config_group' => $groupCode, 'config_key' => $configKey, 'config_value' => $configValue, 'remark' => '内容配置', 'created_at' => $now, 'updated_at' => $now, ]; } } if ($insertRows) { Db::name('system_configs')->insertAll($insertRows); } } private function ensureHelpArticlesTable(): void { $exists = Db::query(sprintf("SHOW TABLES LIKE '%s'", self::HELP_TABLE)); if ($exists) { $this->ensureHelpArticlesContentHtmlColumn(); return; } Db::execute(sprintf( "CREATE TABLE %s ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, category VARCHAR(32) NOT NULL DEFAULT 'service', title VARCHAR(255) NOT NULL DEFAULT '', summary VARCHAR(500) NOT NULL DEFAULT '', keywords_json LONGTEXT NULL, content_html LONGTEXT NULL, content_blocks_json LONGTEXT NULL, is_recommended TINYINT(1) NOT NULL DEFAULT 0, is_enabled TINYINT(1) NOT NULL DEFAULT 1, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_help_articles_category (category), KEY idx_help_articles_enabled (is_enabled), KEY idx_help_articles_sort (sort_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='帮助中心文章'", self::HELP_TABLE )); } private function ensurePolicyHelpArticles(): void { $defaults = $this->defaultPolicyConfig(); $configMap = Db::name('system_configs') ->where('config_group', self::POLICY_GROUP) ->column('config_value', 'config_key'); $policy = [ 'legal_entries' => $this->decodeJsonConfig($configMap['legal_entries_json'] ?? '', $defaults['legal_entries']), 'appraisal_agreements' => $this->decodeJsonConfig($configMap['appraisal_agreements_json'] ?? '', $defaults['appraisal_agreements']), ]; $changed = false; foreach (['legal_entries', 'appraisal_agreements'] as $section) { $items = []; foreach (($policy[$section] ?? []) as $item) { if (!is_array($item)) { continue; } $normalizedItem = $item; $articleId = (int)($normalizedItem['article_id'] ?? 0); if ($articleId <= 0) { $articleId = $this->extractHelpArticleIdFromTargetUrl((string)($normalizedItem['target_url'] ?? '')); } if ($articleId > 0) { $article = Db::name(self::HELP_TABLE)->field(['id'])->where('id', $articleId)->find(); if (!$article) { $articleId = 0; } } if ($articleId <= 0) { $articleId = $this->resolveOrCreatePolicyArticleId((string)($normalizedItem['code'] ?? '')); } if ($articleId > 0) { $nextTargetUrl = $this->buildHelpArticleTargetUrl($articleId); if ((int)($normalizedItem['article_id'] ?? 0) !== $articleId || (string)($normalizedItem['target_url'] ?? '') !== $nextTargetUrl) { $changed = true; } $normalizedItem['article_id'] = $articleId; $normalizedItem['target_url'] = $nextTargetUrl; } $items[] = $normalizedItem; } $policy[$section] = $items; } if (!$changed) { return; } $now = date('Y-m-d H:i:s'); $this->upsertSystemConfig(self::POLICY_GROUP, 'legal_entries_json', json_encode($policy['legal_entries'], JSON_UNESCAPED_UNICODE), $now); $this->upsertSystemConfig(self::POLICY_GROUP, 'appraisal_agreements_json', json_encode($policy['appraisal_agreements'], JSON_UNESCAPED_UNICODE), $now); } private function seedHelpArticles(): void { $count = (int)Db::name(self::HELP_TABLE)->count(); if ($count > 0) { return; } $now = date('Y-m-d H:i:s'); foreach ($this->defaultHelpArticles() as $index => $item) { $contentBlocks = $item['content_blocks']; Db::name(self::HELP_TABLE)->insert([ 'category' => $item['category'], 'title' => $item['title'], 'summary' => $item['summary'], 'keywords_json' => json_encode($item['keywords'], JSON_UNESCAPED_UNICODE), 'content_html' => $this->contentBlocksToHtml($contentBlocks), 'content_blocks_json' => json_encode($contentBlocks, JSON_UNESCAPED_UNICODE), 'is_recommended' => !empty($item['is_recommended']) ? 1 : 0, 'is_enabled' => 1, 'sort_order' => $item['sort_order'] ?? ($index + 1) * 10, 'created_at' => $item['updated_at'] ?? $now, 'updated_at' => $item['updated_at'] ?? $now, ]); } } private function upsertSystemConfig(string $groupCode, string $configKey, string $configValue, string $now): void { $exists = Db::name('system_configs') ->where('config_group', $groupCode) ->where('config_key', $configKey) ->find(); $payload = [ 'config_group' => $groupCode, 'config_key' => $configKey, 'config_value' => $configValue, 'remark' => '内容配置', 'updated_at' => $now, ]; if ($exists) { Db::name('system_configs')->where('id', $exists['id'])->update($payload); return; } $payload['created_at'] = $now; Db::name('system_configs')->insert($payload); } private function decodeJsonConfig(string $value, array $default): array { if ($value === '') { return $default; } $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : $default; } private function decodeJsonObjectConfig(string $value, array $default): array { if ($value === '') { return $default; } $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : $default; } private function normalizeArrayItems(mixed $value, array $keys, array $default): array { if (!is_array($value)) { return $default; } $normalized = []; foreach ($value as $item) { if (!is_array($item)) { continue; } $row = []; foreach ($keys as $key) { $row[$key] = trim((string)($item[$key] ?? '')); } if (implode('', $row) === '') { continue; } $normalized[] = $row; } return $normalized ?: $default; } private function normalizePolicyItems(mixed $value, array $default): array { if (!is_array($value)) { return $this->hydratePolicyItems($default); } $normalized = []; foreach ($value as $item) { if (!is_array($item)) { continue; } $code = trim((string)($item['code'] ?? '')); $title = trim((string)($item['title'] ?? '')); $desc = trim((string)($item['desc'] ?? '')); $targetUrl = trim((string)($item['target_url'] ?? '')); $articleId = (int)($item['article_id'] ?? 0); if ($articleId <= 0) { $articleId = $this->extractHelpArticleIdFromTargetUrl($targetUrl); } if ($articleId > 0) { $article = Db::name(self::HELP_TABLE)->field(['id'])->where('id', $articleId)->find(); if (!$article) { throw new \RuntimeException(sprintf('绑定文章 #%d 不存在,请重新选择', $articleId)); } $targetUrl = $this->buildHelpArticleTargetUrl($articleId); } if ($code === '' && $title === '' && $desc === '' && $targetUrl === '' && $articleId <= 0) { continue; } $normalized[] = [ 'code' => $code, 'title' => $title, 'desc' => $desc, 'target_url' => $targetUrl, 'article_id' => $articleId, ]; } return $normalized ?: $this->hydratePolicyItems($default); } private function hydratePolicyItems(array $items): array { $normalized = []; foreach ($items as $item) { if (!is_array($item)) { continue; } $articleId = (int)($item['article_id'] ?? 0); $targetUrl = trim((string)($item['target_url'] ?? '')); if ($articleId <= 0) { $articleId = $this->extractHelpArticleIdFromTargetUrl($targetUrl); } if ($articleId > 0) { $targetUrl = $this->buildHelpArticleTargetUrl($articleId); } $normalized[] = [ 'code' => trim((string)($item['code'] ?? '')), 'title' => trim((string)($item['title'] ?? '')), 'desc' => trim((string)($item['desc'] ?? '')), 'target_url' => $targetUrl, 'article_id' => $articleId, ]; } return $normalized; } private function extractHelpArticleIdFromTargetUrl(string $targetUrl): int { if ($targetUrl === '') { return 0; } if (!preg_match('/\/pages\/help\/detail\?id=(\d+)/', $targetUrl, $matches)) { return 0; } return (int)($matches[1] ?? 0); } private function buildHelpArticleTargetUrl(int $articleId): string { return sprintf('/pages/help/detail?id=%d', $articleId); } private function findPolicyReferenceByArticleId(int $articleId): ?string { $policy = $this->getPolicyConfig(); foreach (['legal_entries' => '设置页说明入口', 'appraisal_agreements' => '下单确认协议'] as $section => $sectionName) { foreach (($policy[$section] ?? []) as $item) { $itemArticleId = (int)($item['article_id'] ?? 0); if ($itemArticleId <= 0) { $itemArticleId = $this->extractHelpArticleIdFromTargetUrl((string)($item['target_url'] ?? '')); } if ($itemArticleId !== $articleId) { continue; } $title = trim((string)($item['title'] ?? '')); return $title !== '' ? sprintf('%s / %s', $sectionName, $title) : $sectionName; } } return null; } private function normalizeObjectConfig(mixed $value, array $keys, array $default): array { if (!is_array($value)) { return $default; } $normalized = []; foreach ($keys as $key) { $normalized[$key] = trim((string)($value[$key] ?? '')); } return implode('', $normalized) === '' ? $default : $normalized; } private function normalizeStringList(mixed $value, array $default): array { if (!is_array($value)) { return $default; } $normalized = array_values(array_filter(array_map( fn ($item) => trim((string)$item), $value ), fn ($item) => $item !== '')); return $normalized ?: $default; } private function ensureHelpArticlesContentHtmlColumn(): void { $exists = Db::query(sprintf("SHOW COLUMNS FROM %s LIKE 'content_html'", self::HELP_TABLE)); if ($exists) { return; } Db::execute(sprintf( "ALTER TABLE %s ADD COLUMN content_html LONGTEXT NULL AFTER keywords_json", self::HELP_TABLE )); } private function sanitizeHelpArticleHtml(string $html): string { $html = trim($html); if ($html === '') { return ''; } if (!class_exists(\DOMDocument::class)) { return $this->sanitizeHelpArticleHtmlFallback($html); } $document = new \DOMDocument('1.0', 'UTF-8'); $previous = libxml_use_internal_errors(true); $loaded = $document->loadHTML('
' . $html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); libxml_use_internal_errors($previous); if (!$loaded || !$document->documentElement) { return ''; } $this->sanitizeHelpArticleNode($document->documentElement); $output = ''; foreach ($document->documentElement->childNodes as $childNode) { $output .= $document->saveHTML($childNode); } return trim($output); } private function sanitizeHelpArticleNode(\DOMNode $node): void { $allowedTags = ['p', 'br', 'strong', 'em', 'u', 's', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'blockquote', 'a', 'img']; for ($index = $node->childNodes->length - 1; $index >= 0; $index--) { $child = $node->childNodes->item($index); if (!$child) { continue; } if ($child instanceof \DOMElement) { $tagName = strtolower($child->tagName); if (!in_array($tagName, $allowedTags, true)) { if (in_array($tagName, ['script', 'style', 'iframe', 'object', 'embed'], true)) { $node->removeChild($child); continue; } $this->sanitizeHelpArticleNode($child); while ($child->firstChild) { $node->insertBefore($child->firstChild, $child); } $node->removeChild($child); continue; } if ($tagName === 'img' && !$this->isSafeHelpArticleUrl(trim((string)$child->getAttribute('src')))) { $node->removeChild($child); continue; } $this->sanitizeHelpArticleAttributes($child, $tagName); $this->sanitizeHelpArticleNode($child); } elseif ($child->nodeType !== XML_TEXT_NODE) { $node->removeChild($child); } } } private function sanitizeHelpArticleAttributes(\DOMElement $node, string $tagName): void { $href = trim((string)$node->getAttribute('href')); $src = trim((string)$node->getAttribute('src')); $alt = trim((string)$node->getAttribute('alt')); $title = trim((string)$node->getAttribute('title')); while ($node->attributes->length > 0) { $node->removeAttribute($node->attributes->item(0)->name); } if ($tagName === 'a') { if ($this->isSafeHelpArticleUrl($href)) { $node->setAttribute('href', $href); $node->setAttribute('target', '_blank'); $node->setAttribute('rel', 'noopener noreferrer'); } return; } if ($tagName !== 'img') { return; } if ($this->isSafeHelpArticleUrl($src)) { $node->setAttribute('src', $src); } if ($alt !== '') { $node->setAttribute('alt', $this->limitHelpArticleAttribute($alt)); } if ($title !== '') { $node->setAttribute('title', $this->limitHelpArticleAttribute($title)); } $node->setAttribute('loading', 'lazy'); } private function limitHelpArticleAttribute(string $value): string { return function_exists('mb_substr') ? mb_substr($value, 0, 120) : substr($value, 0, 120); } private function sanitizeHelpArticleHtmlFallback(string $html): string { $html = strip_tags($html, '