Files
anxinyan/server-api/app/support/ContentService.php
2026-05-27 14:41:28 +08:00

1297 lines
57 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\support;
use support\think\Db;
class ContentService
{
private const HOME_GROUP = 'app_home_content';
private const POLICY_GROUP = 'app_policy_content';
private const META_GROUP = 'app_meta_content';
private const HELP_TABLE = 'help_articles';
private static bool $bootstrapped = false;
public function bootstrapDefaults(): void
{
if (self::$bootstrapped) {
return;
}
$this->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('<?xml encoding="UTF-8"><div>' . $html . '</div>', 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, '<p><br><strong><em><u><s><h2><h3><h4><ul><ol><li><blockquote><a><img>');
$html = preg_replace('/\s+on[a-z]+\s*=\s*(["\']).*?\1/i', '', $html) ?? '';
$html = preg_replace('/\s+(href|src)\s*=\s*(["\'])\s*(javascript|data):.*?\2/i', '', $html) ?? '';
return trim($html);
}
private function isSafeHelpArticleUrl(string $url): bool
{
if ($url === '') {
return false;
}
return (bool)preg_match('/^(https?:\/\/|\/(?!\/)|#)/i', $url);
}
private function contentBlocksFromHtml(string $html): array
{
$html = preg_replace('/<\/(h2|h3|h4|p|li|blockquote)>/i', "\n", $html) ?? $html;
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return array_values(array_filter(array_map(
fn ($item) => trim((string)preg_replace('/\s+/u', ' ', $item)),
preg_split('/\R/u', $text) ?: []
), fn ($item) => $item !== ''));
}
private function contentBlocksToHtml(array $blocks): string
{
return implode('', array_map(
fn ($item) => '<p>' . htmlspecialchars((string)$item, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>',
$this->normalizeStringList($blocks, [])
));
}
private function defaultHomeConfig(): array
{
return [
'banners' => [
[
'title' => '安心验',
'subtitle' => '独立第三方鉴定服务平台',
'description' => '专业鉴定高价值商品,报告可验真,流程可追踪。',
'background_image_url' => '',
],
],
'page_visuals' => [
'order_background_image_url' => '',
'report_background_image_url' => '',
],
'service_entries' => [
[
'service_provider' => 'anxinyan',
'title' => '实物鉴定',
'tag' => '标准服务',
'description' => '由安心验提供标准实物鉴定服务,适合正式结果交付场景。',
'meta' => '预计 48 小时内出结果 | 报告可验真',
],
[
'service_provider' => 'zhongjian',
'title' => '中检鉴定',
'tag' => '更高规格机构',
'description' => '由更高规格机构提供实物鉴定服务,适合更高要求场景。',
'meta' => '流程一致 | 出具机构不同 | 价格与时效有差异',
],
],
'category_visuals' => [
['category_name' => '奢侈品箱包', 'category_code' => 'luxury_bag', 'image_url' => ''],
['category_name' => '潮流鞋类', 'category_code' => 'sneaker', 'image_url' => ''],
['category_name' => '首饰配饰', 'category_code' => 'jewelry', 'image_url' => ''],
['category_name' => '高端美妆', 'category_code' => 'beauty', 'image_url' => ''],
['category_name' => '腕表', 'category_code' => 'watch', 'image_url' => ''],
['category_name' => '服饰', 'category_code' => 'clothing', 'image_url' => ''],
['category_name' => '3C 数码', 'category_code' => 'digital', 'image_url' => ''],
['category_name' => '古董文玩', 'category_code' => 'antique', 'image_url' => ''],
],
'quick_entries' => [
['code' => 'start', 'title' => '发起鉴定', 'desc' => '进入送检流程'],
['code' => 'orders', 'title' => '我的订单', 'desc' => '查看当前进度'],
['code' => 'reports', 'title' => '我的报告', 'desc' => '查看结果凭证'],
['code' => 'messages', 'title' => '消息中心', 'desc' => '查看服务提醒与结果通知'],
],
'trust_metrics' => [
['value' => '1280+', 'label' => '累计鉴定申请'],
['value' => '48h', 'label' => '标准结果时效'],
['value' => '100%', 'label' => '正式报告可验真'],
],
'trust_points' => [
['title' => '独立第三方', 'desc' => '保持中立判断,不参与买卖立场。'],
['title' => '报告可验真', 'desc' => '每份正式报告均支持编号与状态验证。'],
['title' => '流程可追踪', 'desc' => '从下单到出报告,关键节点一目了然。'],
['title' => '标准化作业', 'desc' => '按模板采集资料,单次鉴定后出具报告。'],
],
'faqs' => [
'实物鉴定和中检鉴定有什么区别?',
'一般多久可以出结果?',
'报告如何验证真伪?',
],
];
}
private function defaultPolicyConfig(): array
{
return [
'legal_entries' => [
[
'code' => 'privacy_policy',
'title' => '隐私说明',
'desc' => '了解平台如何处理您的订单与联系方式信息',
'target_url' => '/pages/help/index?q=%E9%9A%90%E7%A7%81',
'article_id' => 0,
],
[
'code' => 'service_notice',
'title' => '服务与通知说明',
'desc' => '了解消息提醒、工单回复与服务相关通知逻辑',
'target_url' => '/pages/help/index?q=%E6%9C%8D%E5%8A%A1',
'article_id' => 0,
],
],
'appraisal_agreements' => [
[
'code' => 'service_agreement',
'title' => '服务协议',
'desc' => '下单前请确认服务边界、报告用途与责任说明。',
'target_url' => '/pages/help/index?q=%E6%9C%8D%E5%8A%A1',
'article_id' => 0,
],
[
'code' => 'appraisal_notice',
'title' => '鉴定须知',
'desc' => '了解资料要求、流程节点与补资料处理规则。',
'target_url' => '/pages/help/index?q=%E9%89%B4%E5%AE%9A',
'article_id' => 0,
],
[
'code' => 'privacy_policy',
'title' => '隐私政策',
'desc' => '了解平台如何处理联系方式、地址和订单信息。',
'target_url' => '/pages/help/index?q=%E9%9A%90%E7%A7%81',
'article_id' => 0,
],
],
];
}
private function defaultMetaConfig(): array
{
return [
'help_categories' => [
['code' => 'all', 'title' => '全部', 'desc' => '查看全部帮助文章'],
['code' => 'service', 'title' => '服务流程', 'desc' => '了解下单、寄送、鉴定流程'],
['code' => 'report', 'title' => '报告验真', 'desc' => '了解报告查看、下载与验真'],
['code' => 'shipping', 'title' => '寄送物流', 'desc' => '了解寄送、运单和签收说明'],
['code' => 'support', 'title' => '售后支持', 'desc' => '了解补资料、工单和客服协助'],
],
'report_risk_defaults' => [
[
'report_type' => 'appraisal',
'title' => '正式鉴定报告',
'text' => '本报告基于送检商品及当前提交资料出具。若商品状态或所附资料发生变化,报告结论可能不再适用。',
],
[
'report_type' => 'inspection',
'title' => '后台补录检查单',
'text' => '本检查单为后台补录结果,请结合扫码查看的正式页面与现场实物信息综合判断。',
],
],
'ticket_types' => [
[
'code' => 'pre_consultation',
'title' => '鉴定前咨询',
'hint' => '适合流程、服务说明类问题',
'quick_desc' => '下单前流程、服务说明咨询',
],
[
'code' => 'order_issue',
'title' => '订单问题',
'hint' => '适合订单状态、支付、进度问题',
'quick_desc' => '进度、状态、支付相关',
],
[
'code' => 'upload_issue',
'title' => '上传问题',
'hint' => '适合拍摄上传、补图协助问题',
'quick_desc' => '拍摄、上传、补图协助',
],
[
'code' => 'report_issue',
'title' => '报告问题',
'hint' => '适合报告结论、验真、估值问题',
'quick_desc' => '结论、估值、验真咨询',
],
[
'code' => 'after_sales',
'title' => '售后问题',
'hint' => '适合服务反馈与后续处理',
'quick_desc' => '服务反馈与后续处理',
],
[
'code' => 'recheck',
'title' => '结果咨询',
'hint' => '适合咨询报告结论或补充说明',
'quick_desc' => '报告结论与说明咨询',
],
],
'ticket_statuses' => [
['code' => 'pending', 'title' => '待处理', 'desc' => '工单已提交,客服尚未正式开始处理。'],
['code' => 'processing', 'title' => '处理中', 'desc' => '客服正在跟进问题,您可继续补充说明或截图。'],
['code' => 'waiting_user', 'title' => '待您反馈', 'desc' => '客服需要您补充更多信息后才能继续处理。'],
['code' => 'resolved', 'title' => '已解决', 'desc' => '当前问题已处理完成,如仍有疑问可继续留言。'],
['code' => 'closed', 'title' => '已关闭', 'desc' => '工单已关闭,如需继续处理可重新发起工单。'],
],
'message_events' => [
['event_code' => 'order_created', 'title' => '下单成功', 'desc' => '用户成功创建鉴定订单后触发。'],
['event_code' => 'supplement_required', 'title' => '待补资料', 'desc' => '鉴定师发起补资料要求后触发。'],
['event_code' => 'report_published', 'title' => '报告已出具', 'desc' => '正式报告发布成功后触发。'],
['event_code' => 'return_shipped', 'title' => '物品已寄回', 'desc' => '平台登记回寄物流后触发。'],
['event_code' => 'return_received', 'title' => '回寄商品已签收', 'desc' => '用户签收回寄物品后触发。'],
['event_code' => 'ticket_reply', 'title' => '工单有新回复', 'desc' => '客服回复用户工单后触发。'],
['event_code' => 'ticket_waiting_user', 'title' => '工单待用户反馈', 'desc' => '后台将工单状态改为待用户反馈时触发。'],
['event_code' => 'ticket_resolved', 'title' => '工单已解决', 'desc' => '后台将工单状态改为已解决时触发。'],
['event_code' => 'ticket_closed', 'title' => '工单已关闭', 'desc' => '后台将工单状态改为已关闭时触发。'],
],
'message_page_copy' => [
'title' => '服务提醒与处理进度',
'desc' => '这里会统一展示订单流转、补资料、报告出具和工单回复等关键通知,方便您集中查看。',
],
];
}
private function defaultHelpArticles(): array
{
return [
[
'category' => 'service',
'title' => '实物鉴定和中检鉴定有什么区别?',
'summary' => '两种服务的核心流程一致,差异主要体现在出具机构、时效与价格上。',
'keywords' => ['实物鉴定', '中检鉴定', '服务区别'],
'updated_at' => '2026-04-21 09:00:00',
'is_recommended' => true,
'sort_order' => 10,
'content_blocks' => [
'实物鉴定和中检鉴定都会经过下单、填写信息、上传资料、寄送商品、鉴定和查看报告这几个核心步骤。',
'两者最大的区别在于出具机构不同。实物鉴定由安心验提供标准实物鉴定服务;中检鉴定由更高规格合作机构提供服务,适合对机构资质有更高要求的场景。',
'中检鉴定通常价格更高、时效也会略长一些。下单前建议先根据您的使用场景、预算和时效要求选择合适服务。',
],
],
[
'category' => 'service',
'title' => '一般多久可以出结果?',
'summary' => '标准版通常 48 小时左右,具体取决于服务类型、资料完整度和物流节点。',
'keywords' => ['时效', '出结果', '多久'],
'updated_at' => '2026-04-21 09:00:00',
'is_recommended' => true,
'sort_order' => 20,
'content_blocks' => [
'安心验标准版通常在 48 小时左右完成处理,中检鉴定因机构流程要求更高,时效会相对更长。',
'如果您上传的资料不完整、需要补图,或者商品物流尚未签收,整体时效会顺延。',
'建议您在订单详情和消息中心关注关键节点,一旦有补资料要求或报告出具通知,系统会第一时间提醒。',
],
],
[
'category' => 'report',
'title' => '报告如何验证真伪?',
'summary' => '正式报告出具后,可通过报告详情页或验真页输入编号进行验证。',
'keywords' => ['报告', '验真', '验证真伪'],
'updated_at' => '2026-04-21 09:00:00',
'is_recommended' => true,
'sort_order' => 30,
'content_blocks' => [
'正式报告发布后,您可以在报告中心进入报告详情,再点击“去验真”进入验真页面。',
'验真页会展示报告编号、机构、商品摘要和结论摘要。请以验真页显示的结果为准。',
'如果您对报告内容或验真结果仍有疑问,可以直接通过客服工单联系人工支持。',
],
],
[
'category' => 'shipping',
'title' => '商品寄出后还需要做什么?',
'summary' => '寄出商品后,请尽快回到“查看寄送”页填写快递公司和运单号。',
'keywords' => ['寄送', '运单', '物流'],
'updated_at' => '2026-04-21 09:00:00',
'is_recommended' => false,
'sort_order' => 40,
'content_blocks' => [
'寄出商品后,请保留寄件凭证,并尽快在订单详情或寄送页填写快递公司和运单号。',
'提交运单后,订单会显示“已提交运单”,后续签收和处理节点也会继续同步。',
'贵重商品建议选择可追踪快递,并在包裹内附上订单号或鉴定单号。',
],
],
[
'category' => 'support',
'title' => '收到补资料提醒后该怎么处理?',
'summary' => '收到补资料提醒后,请进入订单详情或补资料页,按要求重新上传指定资料。',
'keywords' => ['补资料', '补图', '上传'],
'updated_at' => '2026-04-21 09:00:00',
'is_recommended' => false,
'sort_order' => 50,
'content_blocks' => [
'如果鉴定师认为现有资料还不足以完成判断,系统会推送补资料通知到消息中心。',
'您可以直接点击消息进入补资料页,按要求上传缺失资料,再提交补资料。',
'提交完成后,订单会重新进入鉴定流程。如仍有疑问,也可以通过客服工单寻求协助。',
],
],
[
'category' => 'support',
'title' => '如何联系客服并查看处理进度?',
'summary' => '您可以从订单详情、验真页、“我的”页等入口发起工单,并在工单详情查看客服回复。',
'keywords' => ['客服', '工单', '处理进度'],
'updated_at' => '2026-04-21 09:00:00',
'is_recommended' => false,
'sort_order' => 60,
'content_blocks' => [
'目前用户端已支持发起工单、继续留言、查看客服回复和附件。',
'客服回复后,消息中心会收到提醒,点击即可进入对应工单详情。',
'如果工单状态变成“待您反馈”或“已解决”,系统也会同步推送状态通知。',
],
],
];
}
private function defaultPolicyHelpArticles(): array
{
return [
'privacy_policy' => [
'category' => 'service',
'title' => '隐私政策',
'summary' => '说明平台如何处理联系方式、地址、订单等个人信息,以及相关使用边界。',
'keywords' => ['隐私政策', '个人信息', '联系方式', '地址', '订单信息'],
'is_recommended' => false,
'sort_order' => 70,
'content_blocks' => [
'平台会在您下单、填写寄回地址、提交运单和联系客服等场景中收集必要的信息,仅用于完成鉴定服务、订单履约、结果通知和售后支持。',
'联系方式、地址、订单编号、商品资料和服务记录会用于鉴定流程推进、物流寄回、消息提醒和问题追踪,不会用于与本次服务无关的处理场景。',
'如需了解或更正相关信息,可通过设置页、地址管理、订单详情或客服工单入口进行处理,我们会按平台规则提供协助。',
],
],
'service_notice' => [
'category' => 'service',
'title' => '服务与通知说明',
'summary' => '说明消息提醒、工单回复、补资料和结果通知的触发方式与查看入口。',
'keywords' => ['服务通知', '消息提醒', '工单回复', '补资料', '结果通知'],
'is_recommended' => false,
'sort_order' => 80,
'content_blocks' => [
'订单创建、补资料、报告出具、物品寄回和工单回复等关键节点都会通过消息中心统一提醒,方便您集中查看当前处理进度。',
'若鉴定师需要补充资料,系统会推送待补资料通知,您可直接进入订单详情或补资料页继续上传。',
'若对通知内容有疑问,可通过客服工单继续追问,客服回复后也会再次触发站内提醒。',
],
],
'service_agreement' => [
'category' => 'service',
'title' => '服务协议',
'summary' => '说明鉴定服务边界、报告用途、结果交付方式与相关责任说明。',
'keywords' => ['服务协议', '鉴定服务', '报告用途', '责任说明'],
'is_recommended' => false,
'sort_order' => 90,
'content_blocks' => [
'平台提供的是独立第三方鉴定服务,服务结果基于送检商品、提交资料及实际履约节点综合判断,并以正式页面或报告展示内容为准。',
'不同服务类型在出具机构、价格、时效和交付形式上可能存在差异,下单前请结合自身需求确认所选服务方案。',
'若因资料缺失、物流未签收、商品状态变化或其他需补充核验的情况影响处理进度,平台会通过补资料或消息通知继续提示您后续操作。',
],
],
'appraisal_notice' => [
'category' => 'service',
'title' => '鉴定须知',
'summary' => '说明资料要求、流程节点、补资料处理规则与常见注意事项。',
'keywords' => ['鉴定须知', '资料要求', '流程节点', '补资料'],
'is_recommended' => false,
'sort_order' => 100,
'content_blocks' => [
'请尽量按模板上传清晰、完整的商品资料,并在寄送实物后及时填写物流信息,这会直接影响鉴定效率与处理时效。',
'若当前资料不足以支持判断,鉴定师会发起补资料要求,订单会暂停在待补资料节点,待您补齐后再继续流转。',
'正式报告仅对当前送检商品及本次服务资料负责,如商品状态、附件情况或所附证明材料发生变化,相关结论可能需要重新核验。',
],
],
];
}
private function resolveOrCreatePolicyArticleId(string $code): int
{
$code = trim($code);
if ($code === '') {
return 0;
}
$definitions = $this->defaultPolicyHelpArticles();
$definition = $definitions[$code] ?? null;
if (!$definition) {
return 0;
}
$existing = Db::name(self::HELP_TABLE)
->where('title', $definition['title'])
->find();
if ($existing) {
return (int)$existing['id'];
}
$now = date('Y-m-d H:i:s');
$contentBlocks = $definition['content_blocks'];
return (int)Db::name(self::HELP_TABLE)->insertGetId([
'category' => $definition['category'],
'title' => $definition['title'],
'summary' => $definition['summary'],
'keywords_json' => json_encode($definition['keywords'], JSON_UNESCAPED_UNICODE),
'content_html' => $this->contentBlocksToHtml($contentBlocks),
'content_blocks_json' => json_encode($contentBlocks, JSON_UNESCAPED_UNICODE),
'is_recommended' => !empty($definition['is_recommended']) ? 1 : 0,
'is_enabled' => 1,
'sort_order' => (int)$definition['sort_order'],
'created_at' => $now,
'updated_at' => $now,
]);
}
}