Compare commits

...

7 Commits

Author SHA1 Message Date
root 9727077dfa refactor(admin-web): render citation pie as interactive SVG with hover tooltip
Backend CI / Backend (push) Has been cancelled
Frontend CI / Frontend (push) Has been cancelled
Deployment Config CI / Deployment Config (push) Successful in 21s
Replace the conic-gradient circle with an SVG path-per-slice chart so
slices can lift on hover, dim siblings, and surface a follow-cursor
tooltip with the platform name and share. Falls back to the original
gradient circle when there are no segments to render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:46:35 +08:00
root b345ee26e4 feat(monitoring): match citations by canonical link and scope summary by brand/keyword/platform/date
Replace the domain+title fuzzy alias scoring with a canonical
candidate-key index built from published-link aliases — only exact
matches now resolve to a SaaS source, eliminating false positives
across articles sharing a host (e.g. m.163.com vs www.163.com).

Surface the summary scoping that already existed on loadCitationRanking
/ loadCitedArticles by accepting brand_id, keyword_id, ai_platform_id
and business_date on CitationSummary, plumbing them through the handler
and admin tracking view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:46:17 +08:00
root 2436f50c1f refactor(admin-web): tighten schedule cover picker layout
Replace the labeled cover button + meta column with a compact preview
tile and an overlaid remove button, dropping the now-unused cover-meta
typography rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:04:28 +08:00
root b18a562b32 fix(admin-web): keep app header above content stacking contexts
Promote the header to z-index 100 and create an isolated stacking
context on the main layout so sticky/positioned content (drawers,
overlays) no longer sits above the topbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:04:20 +08:00
root 5a613abc33 chore: rebrand product as 省心推 and lock UI to zh-CN
Update server titles, web/desktop window/tray/document titles, login
copy, and electron build metadata to use the Chinese product name 省心推.
Drop the runtime locale switcher and en-US import — the app now ships
zh-CN only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:03:55 +08:00
root dca36ed1f6 chore(prompts): drop unused target_platform supplement
The target-platform supplement only fed legacy prompt builders that read
the now-removed wizard_state platforms field. Drop the YAML entry,
loader struct field, and runtime helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:02:33 +08:00
root 490c6c759d feat(article): expose auto_publish_platforms derived from publish accounts
Replace the freeform `platforms` field on articles/tasks (parsed out of
wizard_state/input_params JSON) with `auto_publish_platforms`, resolved
by joining schedule publish accounts to platform_accounts. The migration
strips the legacy keys from existing rows so the new field is the single
source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:02:21 +08:00
45 changed files with 1046 additions and 697 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0" content="width=device-width, initial-scale=1.0"
/> />
<title>GEO 工具平台</title> <title>省心推</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+1 -7
View File
@@ -1,18 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import enUS from "ant-design-vue/es/locale/en_US";
import zhCN from "ant-design-vue/es/locale/zh_CN"; import zhCN from "ant-design-vue/es/locale/zh_CN";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import ImageUploadProgressOverlay from "@/components/ImageUploadProgressOverlay.vue"; import ImageUploadProgressOverlay from "@/components/ImageUploadProgressOverlay.vue";
const { locale } = useI18n();
const antLocale = computed(() => (locale.value === "en-US" ? enUS : zhCN));
</script> </script>
<template> <template>
<a-config-provider <a-config-provider
:locale="antLocale" :locale="zhCN"
:theme="{ :theme="{
token: { token: {
colorPrimary: '#1677ff', colorPrimary: '#1677ff',
@@ -116,7 +116,7 @@ const deleteMutation = useMutation({
const columns = computed<TableColumnsType<ArticleListItem>>(() => [ const columns = computed<TableColumnsType<ArticleListItem>>(() => [
{ title: t("custom.article.titleColumn"), dataIndex: "title", key: "title", width: 340 }, { title: t("custom.article.titleColumn"), dataIndex: "title", key: "title", width: 340 },
{ title: t("custom.filters.promptRule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 160 }, { title: t("custom.filters.promptRule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 160 },
{ title: t("custom.schedule.platform"), dataIndex: "platforms", key: "platforms", width: 180 }, { title: t("custom.schedule.autoPublishPlatforms"), dataIndex: "auto_publish_platforms", key: "auto_publish_platforms", width: 180 },
{ title: t("common.generateStatus"), dataIndex: "generate_status", key: "generate_status", width: 128 }, { title: t("common.generateStatus"), dataIndex: "generate_status", key: "generate_status", width: 128 },
{ title: t("common.publishStatus"), dataIndex: "publish_status", key: "publish_status", width: 128 }, { title: t("common.publishStatus"), dataIndex: "publish_status", key: "publish_status", width: 128 },
{ title: t("common.wordCount"), dataIndex: "word_count", key: "word_count", width: 100 }, { title: t("common.wordCount"), dataIndex: "word_count", key: "word_count", width: 100 },
@@ -319,8 +319,8 @@ onBeforeUnmount(() => {
<template v-else-if="column.key === 'prompt_rule_name'"> <template v-else-if="column.key === 'prompt_rule_name'">
{{ record.prompt_rule_name || "--" }} {{ record.prompt_rule_name || "--" }}
</template> </template>
<template v-else-if="column.key === 'platforms'"> <template v-else-if="column.key === 'auto_publish_platforms'">
{{ formatPublishPlatformList(record.platforms) }} {{ formatPublishPlatformList(record.auto_publish_platforms) }}
</template> </template>
<template v-else-if="column.key === 'generate_status'"> <template v-else-if="column.key === 'generate_status'">
<ArticleGenerateStatus :status="record.generate_status" /> <ArticleGenerateStatus :status="record.generate_status" />
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { PlusOutlined } from "@ant-design/icons-vue"; import { MinusCircleFilled, PlusOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue"; import { message } from "ant-design-vue";
import type { PromptRuleSimple, ScheduleTask } from "@geo/shared-types"; import type { PromptRuleSimple, ScheduleTask } from "@geo/shared-types";
@@ -503,26 +503,28 @@ function padTime(value: string): string {
{{ scheduleCoverRequired ? t("custom.task.scheduleCoverRequiredHint") : t("custom.task.scheduleCoverHint") }} {{ scheduleCoverRequired ? t("custom.task.scheduleCoverRequiredHint") : t("custom.task.scheduleCoverHint") }}
</p> </p>
<div v-if="effectiveScheduleCoverEnabled" class="generate-task-drawer__cover-row"> <div v-if="effectiveScheduleCoverEnabled" class="generate-task-drawer__cover-body">
<button type="button" class="generate-task-drawer__cover" @click="coverPickerOpen = true"> <div class="generate-task-drawer__cover-preview-wrap">
<button type="button" class="generate-task-drawer__cover-preview" @click="coverPickerOpen = true">
<template v-if="form.coverAssetUrl"> <template v-if="form.coverAssetUrl">
<img :src="form.coverAssetUrl" alt="cover preview" /> <img :src="form.coverAssetUrl" alt="cover preview" />
</template> </template>
<template v-else> <template v-else>
<span class="generate-task-drawer__cover-plus">+</span> <span class="generate-task-drawer__cover-plus">+</span>
<strong>{{ t("custom.task.coverUpload") }}</strong> <span>{{ t("custom.task.coverUpload") }}</span>
<small>{{ t("custom.task.scheduleCoverUploadHint") }}</small>
</template> </template>
</button> </button>
<div v-if="form.coverAssetUrl" class="generate-task-drawer__cover-meta"> <button
<strong>{{ form.coverFileName || t("custom.task.cover") }}</strong> v-if="form.coverAssetUrl"
<a-button size="small" @click="coverPickerOpen = true"> type="button"
{{ t("article.editor.coverReplace") }} class="generate-task-drawer__cover-remove"
</a-button> :aria-label="t('article.editor.coverRemove')"
<a-button size="small" danger @click="handleRemoveScheduleCover"> :title="t('article.editor.coverRemove')"
{{ t("article.editor.coverRemove") }} @click.stop="handleRemoveScheduleCover"
</a-button> >
<MinusCircleFilled />
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -806,8 +808,7 @@ function padTime(value: string): string {
gap: 4px; gap: 4px;
} }
.generate-task-drawer__account-copy strong, .generate-task-drawer__account-copy strong {
.generate-task-drawer__cover-meta strong {
overflow: hidden; overflow: hidden;
color: #1f2937; color: #1f2937;
font-size: 14px; font-size: 14px;
@@ -846,7 +847,7 @@ function padTime(value: string): string {
.generate-task-drawer__cover-head { .generate-task-drawer__cover-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
gap: 12px; gap: 12px;
} }
@@ -875,71 +876,72 @@ function padTime(value: string): string {
line-height: 1.7; line-height: 1.7;
} }
.generate-task-drawer__cover-row { .generate-task-drawer__cover-body {
display: flex; display: flex;
align-items: center; margin-top: 12px;
gap: 16px;
flex-wrap: wrap;
} }
.generate-task-drawer__cover { .generate-task-drawer__cover-preview-wrap {
position: relative;
width: 176px;
flex: 0 0 176px;
}
.generate-task-drawer__cover-preview {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
width: 228px; width: 100%;
min-height: 172px; min-height: 124px;
padding: 0; padding: 0;
border: 1px dashed #cfd9e8; border: 1px dashed #d1d5db;
border-radius: 8px; border-radius: 8px;
background: #fbfcff; background: #f9fafb;
color: #1f2937; color: #6b7280;
font-size: 13px;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
transition: all 0.2s ease;
} }
.generate-task-drawer__cover:hover { .generate-task-drawer__cover-preview:hover {
border-color: #9db7ff; border-color: #6366f1;
background: #f7faff; background: #f5f3ff;
color: #6366f1;
} }
.generate-task-drawer__cover img { .generate-task-drawer__cover-preview img {
display: block;
width: 100%; width: 100%;
height: 172px; height: 124px;
object-fit: cover; object-fit: cover;
} }
.generate-task-drawer__cover strong {
font-size: 16px;
font-weight: 700;
}
.generate-task-drawer__cover small {
color: #667085;
font-size: 13px;
}
.generate-task-drawer__cover-plus { .generate-task-drawer__cover-plus {
display: inline-flex; font-size: 20px;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 999px;
background: #eef4ff;
color: #355dff;
font-size: 28px;
line-height: 1; line-height: 1;
} }
.generate-task-drawer__cover-meta { .generate-task-drawer__cover-remove {
display: flex; position: absolute;
min-width: 220px; top: 8px;
max-width: 360px; right: 8px;
flex-direction: column; display: inline-flex;
align-items: flex-start; align-items: center;
gap: 8px; justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
background: rgba(17, 24, 39, 0.7);
color: #fff;
cursor: pointer;
transition: background 0.2s ease;
}
.generate-task-drawer__cover-remove:hover {
background: rgba(17, 24, 39, 0.9);
} }
.generate-task-drawer__time-wrapper { .generate-task-drawer__time-wrapper {
@@ -80,7 +80,7 @@ const columns = computed<TableColumnsType<InstantTaskItem>>(() => [
{ title: t("custom.schedule.rule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 180 }, { title: t("custom.schedule.rule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 180 },
{ title: t("custom.instant.executionStatus"), dataIndex: "status", key: "status", width: 140 }, { title: t("custom.instant.executionStatus"), dataIndex: "status", key: "status", width: 140 },
{ title: t("custom.instant.executionTime"), dataIndex: "execution_time", key: "execution_time", width: 170 }, { title: t("custom.instant.executionTime"), dataIndex: "execution_time", key: "execution_time", width: 170 },
{ title: t("custom.schedule.platform"), dataIndex: "platforms", key: "platforms", width: 220 }, { title: t("custom.schedule.autoPublishPlatforms"), dataIndex: "auto_publish_platforms", key: "auto_publish_platforms", width: 220 },
{ title: t("custom.task.generateCount"), dataIndex: "generate_count", key: "generate_count", width: 120 }, { title: t("custom.task.generateCount"), dataIndex: "generate_count", key: "generate_count", width: 120 },
{ title: t("custom.instant.createdTime"), dataIndex: "created_at", key: "created_at", width: 170 }, { title: t("custom.instant.createdTime"), dataIndex: "created_at", key: "created_at", width: 170 },
{ title: t("common.actions"), key: "actions", width: 88, fixed: "right", align: "right" }, { title: t("common.actions"), key: "actions", width: 88, fixed: "right", align: "right" },
@@ -235,8 +235,8 @@ onBeforeUnmount(() => {
<template v-else-if="column.key === 'execution_time'"> <template v-else-if="column.key === 'execution_time'">
{{ formatDateTime(record.execution_time) }} {{ formatDateTime(record.execution_time) }}
</template> </template>
<template v-else-if="column.key === 'platforms'"> <template v-else-if="column.key === 'auto_publish_platforms'">
{{ formatPublishPlatformList(record.platforms) }} {{ formatPublishPlatformList(record.auto_publish_platforms) }}
</template> </template>
<template v-else-if="column.key === 'generate_count'"> <template v-else-if="column.key === 'generate_count'">
{{ record.generate_count || 1 }} {{ record.generate_count || 1 }}
@@ -21,9 +21,6 @@ import {
publishStateLabel, publishStateLabel,
type PublishAccountCard, type PublishAccountCard,
} from "@/lib/publish-account-cards"; } from "@/lib/publish-account-cards";
import {
normalizePublishPlatformIds,
} from "@/lib/publish-platforms";
const props = defineProps<{ const props = defineProps<{
open: boolean; open: boolean;
@@ -93,10 +90,8 @@ watch(
const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? [])); const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? []));
const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuery.data.value?.platforms ?? []));
const accountCards = computed<PublishAccountCard[]>(() => const accountCards = computed<PublishAccountCard[]>(() =>
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value, articlePlatformIds.value), buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value),
); );
watch( watch(
@@ -137,10 +132,7 @@ watchEffect(() => {
return; return;
} }
const preferredPlatforms = new Set(articlePlatformIds.value); selectedAccountIds.value = [];
selectedAccountIds.value = accountCards.value
.filter((card) => card.selectable && preferredPlatforms.has(card.platformId))
.map((card) => card.id);
selectionHydrated.value = true; selectionHydrated.value = true;
}); });
@@ -237,7 +229,6 @@ async function persistCoverIfNeeded(detail: ArticleDetail): Promise<ArticleDetai
return articlesApi.update(detail.id, { return articlesApi.update(detail.id, {
title: detail.title?.trim() || t("article.untitled"), title: detail.title?.trim() || t("article.untitled"),
markdown_content: detail.markdown_content ?? "", markdown_content: detail.markdown_content ?? "",
platforms: normalizePublishPlatformIds(detail.platforms ?? []),
cover_asset_url: nextUrl || null, cover_asset_url: nextUrl || null,
cover_image_asset_id: coverImageAssetId.value, cover_image_asset_id: coverImageAssetId.value,
}); });
@@ -16,6 +16,7 @@ import ScheduleTaskModal from "@/components/ScheduleTaskModal.vue";
import { promptRulesApi, schedulesApi } from "@/lib/api"; import { promptRulesApi, schedulesApi } from "@/lib/api";
import { formatCronExecutionTime, formatDateTime } from "@/lib/display"; import { formatCronExecutionTime, formatDateTime } from "@/lib/display";
import { formatError } from "@/lib/errors"; import { formatError } from "@/lib/errors";
import { formatPublishPlatformList } from "@/lib/publish-platforms";
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useI18n(); const { t } = useI18n();
@@ -84,7 +85,7 @@ const columns = computed<TableColumnsType<ScheduleTask>>(() => [
{ title: t("custom.schedule.rule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 180 }, { title: t("custom.schedule.rule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 180 },
{ title: t("custom.instant.executionStatus"), dataIndex: "status", key: "status", width: 140 }, { title: t("custom.instant.executionStatus"), dataIndex: "status", key: "status", width: 140 },
{ title: t("custom.instant.executionTime"), dataIndex: "cron_expr", key: "cron_expr", width: 170 }, { title: t("custom.instant.executionTime"), dataIndex: "cron_expr", key: "cron_expr", width: 170 },
{ title: t("custom.schedule.autoPublish"), dataIndex: "auto_publish", key: "auto_publish", width: 160 }, { title: t("custom.schedule.autoPublishPlatforms"), dataIndex: "auto_publish_platforms", key: "auto_publish_platforms", width: 200 },
{ title: t("custom.task.generateCount"), dataIndex: "generate_count", key: "generate_count", width: 120 }, { title: t("custom.task.generateCount"), dataIndex: "generate_count", key: "generate_count", width: 120 },
{ title: t("custom.instant.createdTime"), dataIndex: "created_at", key: "created_at", width: 170 }, { title: t("custom.instant.createdTime"), dataIndex: "created_at", key: "created_at", width: 170 },
{ title: t("common.actions"), key: "actions", width: 140, fixed: "right", align: "right" }, { title: t("common.actions"), key: "actions", width: 140, fixed: "right", align: "right" },
@@ -222,11 +223,11 @@ function handleTableChange(nextPage: number, nextPageSize: number): void {
<template v-else-if="column.key === 'cron_expr'"> <template v-else-if="column.key === 'cron_expr'">
{{ formatCronExecutionTime(record.cron_expr) }} {{ formatCronExecutionTime(record.cron_expr) }}
</template> </template>
<template v-else-if="column.key === 'auto_publish'"> <template v-else-if="column.key === 'auto_publish_platforms'">
<a-tag :color="record.auto_publish ? 'blue' : 'default'"> <a-tag :color="record.auto_publish ? 'blue' : 'default'">
{{ {{
record.auto_publish record.auto_publish
? t("custom.schedule.autoPublishAccountCount", { count: record.publish_account_ids?.length ?? 0 }) ? formatPublishPlatformList(record.auto_publish_platforms)
: t("custom.schedule.manualPublish") : t("custom.schedule.manualPublish")
}} }}
</a-tag> </a-tag>
+3 -33
View File
@@ -1,44 +1,14 @@
import { createI18n } from "vue-i18n"; import { createI18n } from "vue-i18n";
import enUS from "./messages/en-US";
import zhCN from "./messages/zh-CN"; import zhCN from "./messages/zh-CN";
export const LOCALE_STORAGE_KEY = "geo.admin.locale"; const APP_LOCALE = "zh-CN";
export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
function resolveLocale(): AppLocale {
if (typeof window === "undefined") {
return "zh-CN";
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && SUPPORTED_LOCALES.includes(stored as AppLocale)) {
return stored as AppLocale;
}
const browserLocale = window.navigator.language;
if (browserLocale.startsWith("zh")) {
return "zh-CN";
}
return "en-US";
}
export const i18n = createI18n({ export const i18n = createI18n({
legacy: false, legacy: false,
locale: resolveLocale(), locale: APP_LOCALE,
fallbackLocale: "en-US", fallbackLocale: APP_LOCALE,
messages: { messages: {
"zh-CN": zhCN, "zh-CN": zhCN,
"en-US": enUS,
}, },
}); });
export function setAppLocale(locale: AppLocale): void {
i18n.global.locale.value = locale;
if (typeof window !== "undefined") {
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
}
}
+2 -2
View File
@@ -432,7 +432,7 @@ const enUS = {
backToQuestions: "Back to questions", backToQuestions: "Back to questions",
citationRanking: "Citation Ranking", citationRanking: "Citation Ranking",
citationRankingTitle: "Citation Ranking", citationRankingTitle: "Citation Ranking",
citationRankingHint: "Attribute SaaS-published content by registrable domain across model citation sources.", citationRankingHint: "Attribute SaaS-published content by published-link identity across model citation sources.",
citationWindow7: "Last 7 days", citationWindow7: "Last 7 days",
citationWindow30: "Last 30 days", citationWindow30: "Last 30 days",
citationWindowLabel: "Last {days} days", citationWindowLabel: "Last {days} days",
@@ -1290,9 +1290,9 @@ const enUS = {
name: "Task name", name: "Task name",
rule: "Prompt rule", rule: "Prompt rule",
cron: "Frequency", cron: "Frequency",
autoPublishPlatforms: "Auto publish platforms",
autoPublish: "Auto publish", autoPublish: "Auto publish",
manualPublish: "Manual publish", manualPublish: "Manual publish",
autoPublishAccountCount: "Auto · {count} accounts",
nextRun: "Next run", nextRun: "Next run",
startAt: "Start time", startAt: "Start time",
endAt: "End time", endAt: "End time",
+5 -5
View File
@@ -1,7 +1,7 @@
const zhCN = { const zhCN = {
app: { app: {
name: "GEO工具平台", name: "省心推",
loginIntro: "面向企业的品牌资产管理与内容生产协作系统", loginIntro: "面向企业和个人的品牌资产管理与内容生产协作系统",
}, },
locale: { locale: {
"zh-CN": "中文简体", "zh-CN": "中文简体",
@@ -153,7 +153,7 @@ const zhCN = {
route: { route: {
login: { login: {
title: "欢迎回来", title: "欢迎回来",
description: "使用真实租户账号登录 GEO 工具平台。", description: "登录省心推,开启品牌内容创作之旅",
}, },
workspace: { workspace: {
title: "工作台", title: "工作台",
@@ -433,7 +433,7 @@ const zhCN = {
backToQuestions: "返回问题列表", backToQuestions: "返回问题列表",
citationRanking: "Citation Ranking", citationRanking: "Citation Ranking",
citationRankingTitle: "引用排行", citationRankingTitle: "引用排行",
citationRankingHint: "按主体域名归因 SaaS 发文在模型引用来源中的出现次数与占比。", citationRankingHint: "按发布外链主体归因 SaaS 发文在模型引用来源中的出现次数与占比。",
citationWindow7: "近 7 天", citationWindow7: "近 7 天",
citationWindow30: "近 30 天", citationWindow30: "近 30 天",
citationWindowLabel: "近 {days} 天", citationWindowLabel: "近 {days} 天",
@@ -1300,9 +1300,9 @@ const zhCN = {
name: "任务名称", name: "任务名称",
rule: "关联规则", rule: "关联规则",
cron: "执行频率", cron: "执行频率",
autoPublishPlatforms: "自动发布平台",
autoPublish: "自动发布", autoPublish: "自动发布",
manualPublish: "手动发布", manualPublish: "手动发布",
autoPublishAccountCount: "自动 · {count} 个账号",
nextRun: "下次执行", nextRun: "下次执行",
startAt: "开始时间", startAt: "开始时间",
endAt: "结束时间", endAt: "结束时间",
+4 -69
View File
@@ -16,7 +16,6 @@ import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { workspaceApi } from "@/lib/api"; import { workspaceApi } from "@/lib/api";
import { type AppLocale, setAppLocale } from "@/i18n";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
const route = useRoute(); const route = useRoute();
@@ -86,22 +85,6 @@ type NavSection = {
items: NavItem[]; items: NavItem[];
}; };
const localeOptions = computed(() => [
{ label: t("locale.zh-CN"), value: "zh-CN" },
{ label: t("locale.en-US"), value: "en-US" },
]);
const currentLocale = computed({
get: () => locale.value as AppLocale,
set: (value: AppLocale) => {
setAppLocale(value);
},
});
const currentLocaleLabel = computed(() => {
return localeOptions.value.find((opt) => opt.value === currentLocale.value)?.label ?? "Language";
});
const navSections = computed<NavSection[]>(() => { const navSections = computed<NavSection[]>(() => {
const base = [ const base = [
{ {
@@ -220,10 +203,6 @@ function go(path: string): void {
void router.push(path); void router.push(path);
} }
function handleLocaleChange({ key }: { key: any }): void {
currentLocale.value = key as AppLocale;
}
async function handleLogout(): Promise<void> { async function handleLogout(): Promise<void> {
await authStore.logout(); await authStore.logout();
await router.replace("/login"); await router.replace("/login");
@@ -298,22 +277,6 @@ async function handleLogout(): Promise<void> {
</nav> </nav>
<div class="admin-header-actions"> <div class="admin-header-actions">
<div v-if="!isMembershipBlocked" class="admin-locale-switch">
<a-dropdown placement="bottomRight" :trigger="['click', 'hover']">
<div class="locale-dropdown-trigger">
<GlobalOutlined class="locale-icon" />
<span>{{ currentLocaleLabel }}</span>
</div>
<template #overlay>
<a-menu :selected-keys="[currentLocale]" @click="handleLocaleChange">
<a-menu-item v-for="opt in localeOptions" :key="opt.value">
{{ opt.label }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<!-- Quota Indicator --> <!-- Quota Indicator -->
<div v-if="!isMembershipBlocked" class="quota-pill"> <div v-if="!isMembershipBlocked" class="quota-pill">
<a-tag color="blue" :bordered="false" class="quota-pill-tag">{{ quotaSummary?.plan_name || t("shell.planFallback") }}</a-tag> <a-tag color="blue" :bordered="false" class="quota-pill-tag">{{ quotaSummary?.plan_name || t("shell.planFallback") }}</a-tag>
@@ -390,6 +353,7 @@ async function handleLogout(): Promise<void> {
.admin-main-layout { .admin-main-layout {
margin-left: 250px; margin-left: 250px;
isolation: isolate;
} }
.admin-main-layout--blocked { .admin-main-layout--blocked {
@@ -424,7 +388,7 @@ async function handleLogout(): Promise<void> {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
z-index: 1; z-index: 100;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
} }
@@ -487,37 +451,6 @@ async function handleLogout(): Promise<void> {
margin-left: auto; margin-left: auto;
} }
.admin-locale-switch {
display: flex;
align-items: center;
}
.admin-locale-select {
width: 100px;
}
.locale-dropdown-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
height: 32px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
color: #141414;
}
.locale-dropdown-trigger:hover {
background: #f5f5f5;
}
.locale-icon {
font-size: 16px;
color: #595959;
}
.quota-pill { .quota-pill {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -620,6 +553,8 @@ async function handleLogout(): Promise<void> {
} }
.admin-content { .admin-content {
position: relative;
z-index: 0;
margin: 24px; margin: 24px;
min-height: auto; min-height: auto;
} }
+4
View File
@@ -1078,6 +1078,10 @@ export const monitoringApi = {
}, },
citationSummary(params: { citationSummary(params: {
days?: number; days?: number;
brand_id?: number;
keyword_id?: number | null;
business_date?: string;
ai_platform_id?: string;
}) { }) {
return apiClient.get<MonitoringCitationSummaryResponse>("/api/tenant/monitoring/citation-summary", { return apiClient.get<MonitoringCitationSummaryResponse>("/api/tenant/monitoring/citation-summary", {
params, params,
-1
View File
@@ -140,7 +140,6 @@ html, body, #app {
.ant-layout-header { .ant-layout-header {
box-shadow: 0 1px 4px rgba(0,21,41,0.08); /* slight shadow for topbar */ box-shadow: 0 1px 4px rgba(0,21,41,0.08); /* slight shadow for topbar */
z-index: 1;
} }
.ant-card { .ant-card {
+1 -1
View File
@@ -161,7 +161,7 @@ const recentTemplateArticles = computed<ArticleListItem[]>(() =>
prompt_rule_id: null, prompt_rule_id: null,
prompt_rule_name: null, prompt_rule_name: null,
generation_mode: article.generation_mode, generation_mode: article.generation_mode,
platforms: [], auto_publish_platforms: [],
generate_status: article.generate_status, generate_status: article.generate_status,
publish_status: article.publish_status, publish_status: article.publish_status,
title: article.title, title: article.title,
+161 -13
View File
@@ -13,7 +13,7 @@ import type {
MonitoringPlatformAuthorizationStatus, MonitoringPlatformAuthorizationStatus,
} from "@geo/shared-types"; } from "@geo/shared-types";
import { aiPlatformCatalog } from "@geo/shared-types"; import { aiPlatformCatalog } from "@geo/shared-types";
import { computed, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
@@ -251,10 +251,19 @@ const citationSummaryQuery = useQuery({
queryKey: computed(() => [ queryKey: computed(() => [
"tracking", "tracking",
"citation-summary", "citation-summary",
selectedBrandId.value,
selectedKeywordId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
selectedCitationWindowDays.value, selectedCitationWindowDays.value,
]), ]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => queryFn: () =>
monitoringApi.citationSummary({ monitoringApi.citationSummary({
brand_id: selectedBrandId.value ?? undefined,
keyword_id: selectedKeywordId.value,
business_date: selectedBusinessDate.value,
ai_platform_id: selectedPlatformId.value !== "all" ? selectedPlatformId.value : undefined,
days: selectedCitationWindowDays.value, days: selectedCitationWindowDays.value,
}), }),
}); });
@@ -495,25 +504,72 @@ const visibleCitedArticles = computed(() => {
return citationSummaryQuery.data.value?.cited_articles ?? []; return citationSummaryQuery.data.value?.cited_articles ?? [];
}); });
const citationChartStyle = computed(() => { const citationPieSlices = computed(() => {
const segments = citationRankingRows.value.filter((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0); const segments = citationRankingRows.value.filter((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0);
if (!segments.length) { if (!segments.length) {
return [];
}
const cx = 100;
const cy = 100;
const r = 100;
let offset = 0;
return segments.map((item) => {
const share = item.share;
if (share === 1) {
return { return {
background: "linear-gradient(180deg, #eef4ff 0%, #e5ecff 100%)", ...item,
isFull: true,
path: "",
}; };
} }
let offset = 0; const startAngle = offset * 2 * Math.PI - Math.PI / 2;
const gradientStops = segments.map((item) => { const endAngle = (offset + share) * 2 * Math.PI - Math.PI / 2;
const next = offset + item.share * 360;
const stop = `${item.color} ${offset.toFixed(2)}deg ${next.toFixed(2)}deg`; const x1 = cx + r * Math.cos(startAngle);
offset = next; const y1 = cy + r * Math.sin(startAngle);
return stop; const x2 = cx + r * Math.cos(endAngle);
}); const y2 = cy + r * Math.sin(endAngle);
const largeArcFlag = share > 0.5 ? 1 : 0;
const path = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArcFlag} 1 ${x2} ${y2} Z`;
offset += share;
return { return {
background: `conic-gradient(${gradientStops.join(", ")})`, ...item,
isFull: false,
path,
};
});
});
const hoveredSlice = ref<string | null>(null);
const tooltipPos = ref({ x: 0, y: 0 });
function onPieMouseMove(e: MouseEvent) {
const target = e.currentTarget as HTMLElement;
if (!target) return;
const rect = target.getBoundingClientRect();
tooltipPos.value = {
x: e.clientX - rect.left + 15,
y: e.clientY - rect.top + 15
};
}
const hoveredSliceInfo = computed(() => {
if (!hoveredSlice.value) return null;
return citationPieSlices.value.find(s => s.ai_platform_id === hoveredSlice.value) ?? null;
});
const citationChartStyle = computed(() => {
return {
background: "linear-gradient(180deg, #eef4ff 0%, #e5ecff 100%)",
}; };
}); });
@@ -918,8 +974,38 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
<div class="citation-ranking-layout"> <div class="citation-ranking-layout">
<div class="citation-ranking-visual"> <div class="citation-ranking-visual">
<div class="citation-ranking-visual__canvas"> <div class="citation-ranking-visual__canvas" @mouseleave="hoveredSlice = null" @mousemove="onPieMouseMove">
<div class="citation-ranking-visual__circle" :style="citationChartStyle" /> <svg v-if="citationPieSlices.length" viewBox="0 0 200 200" class="citation-pie-svg">
<template v-for="slice in citationPieSlices" :key="slice.ai_platform_id">
<circle
v-if="slice.isFull"
cx="100" cy="100" r="100"
:fill="slice.color"
class="citation-pie-slice"
:class="{ 'is-hovered': hoveredSlice === slice.ai_platform_id }"
@mouseenter="hoveredSlice = slice.ai_platform_id"
/>
<path
v-else
:d="slice.path"
:fill="slice.color"
class="citation-pie-slice"
:class="{ 'is-hovered': hoveredSlice === slice.ai_platform_id, 'is-dimmed': hoveredSlice && hoveredSlice !== slice.ai_platform_id }"
@mouseenter="hoveredSlice = slice.ai_platform_id"
/>
</template>
</svg>
<div v-else class="citation-ranking-visual__circle" :style="citationChartStyle" />
<div
v-if="hoveredSliceInfo"
class="citation-pie-tooltip"
:style="{ left: `${tooltipPos.x}px`, top: `${tooltipPos.y}px` }"
>
<div class="citation-pie-tooltip__marker" :style="{ background: hoveredSliceInfo.color }"></div>
<span class="citation-pie-tooltip__label">{{ hoveredSliceInfo.platform_name }}</span>
<strong class="citation-pie-tooltip__value">{{ formatPercent(hoveredSliceInfo.share) }}</strong>
</div>
</div> </div>
<div v-if="citationRankingRows.length" class="citation-ranking-legend"> <div v-if="citationRankingRows.length" class="citation-ranking-legend">
@@ -1390,12 +1476,74 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
} }
.citation-ranking-visual__canvas { .citation-ranking-visual__canvas {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
.citation-pie-svg {
width: min(32vw, 420px);
height: min(32vw, 420px);
min-width: 280px;
min-height: 280px;
border-radius: 999px;
overflow: visible;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.05));
}
.citation-pie-slice {
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s;
transform-origin: 100px 100px;
cursor: pointer;
stroke: #ffffff;
stroke-width: 1.5px;
stroke-linejoin: round;
}
.citation-pie-slice.is-hovered {
transform: scale(1.05);
z-index: 2;
}
.citation-pie-slice.is-dimmed {
opacity: 0.4;
}
.citation-pie-tooltip {
position: absolute;
pointer-events: none;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(4px);
border: 1px solid #e5e7eb;
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 8px;
z-index: 10;
font-size: 13px;
white-space: nowrap;
transition: opacity 0.2s;
}
.citation-pie-tooltip__marker {
width: 8px;
height: 8px;
border-radius: 50%;
}
.citation-pie-tooltip__label {
color: #4b5563;
}
.citation-pie-tooltip__value {
color: #111827;
font-weight: 600;
}
.citation-ranking-visual__circle { .citation-ranking-visual__circle {
width: min(32vw, 420px); width: min(32vw, 420px);
height: min(32vw, 420px); height: min(32vw, 420px);
+2 -2
View File
@@ -1,6 +1,6 @@
appId: com.geo.rankly.desktop appId: com.geo.rankly.desktop
productName: GEO Rankly Desktop productName: 省心推
copyright: Copyright © 2026 GEO Rankly copyright: Copyright © 2026 shengxintui.com. All Rights Reserved.
directories: directories:
output: release output: release
buildResources: build buildResources: build
+4 -3
View File
@@ -2,10 +2,11 @@
"name": "@geo/desktop-client", "name": "@geo/desktop-client",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"description": "GEO Rankly Desktop — account health, monitor scheduler, and publish dispatch client.", "description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。",
"author": { "author": {
"name": "GEO Rankly", "name": "Liang Xu",
"email": "support@geo-rankly.local" "email": "liangxu@qq.com",
"url": "https://shengxintui.com"
}, },
"type": "module", "type": "module",
"main": "out/main/bootstrap.cjs", "main": "out/main/bootstrap.cjs",
+2 -2
View File
@@ -340,7 +340,7 @@ async function createAppWindow(mode: WindowMode): Promise<ElectronBrowserWindow>
minWidth: initial.minWidth, minWidth: initial.minWidth,
minHeight: initial.minHeight, minHeight: initial.minHeight,
show: false, show: false,
title: "GEO Rankly Desktop", title: "省心推",
icon: createDesktopHealthIcon("normal"), icon: createDesktopHealthIcon("normal"),
titleBarStyle: "hiddenInset", titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 14 }, trafficLightPosition: { x: 12, y: 14 },
@@ -414,7 +414,7 @@ async function createSettingsWindow(): Promise<ElectronBrowserWindow> {
minWidth: initial.minWidth, minWidth: initial.minWidth,
minHeight: initial.minHeight, minHeight: initial.minHeight,
show: false, show: false,
title: "GEO Rankly Desktop 设置", title: "省心推设置",
icon: createDesktopHealthIcon("normal"), icon: createDesktopHealthIcon("normal"),
titleBarStyle: "hiddenInset", titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 14, y: 15 }, trafficLightPosition: { x: 14, y: 15 },
@@ -25,7 +25,7 @@ describe("playwright cdp target pruning", () => {
expect(isReclaimableHiddenPlaywrightTarget({ expect(isReclaimableHiddenPlaywrightTarget({
id: "renderer", id: "renderer",
type: "page", type: "page",
title: "GEO Rankly Desktop", title: "省心推 - 账号健康监控与内容发布平台",
url: "http://localhost:5173/", url: "http://localhost:5173/",
})).toBe(false); })).toBe(false);
}); });
@@ -165,7 +165,7 @@ function createLiveRuntimeSnapshot(controller: RuntimeControllerSnapshot) {
return { return {
generatedAt: now, generatedAt: now,
app: { app: {
name: "GEO Rankly Desktop", name: "省心推",
version: app.getVersion(), version: app.getVersion(),
channel: process.env.NODE_ENV === "development" ? "dev" : "release", channel: process.env.NODE_ENV === "development" ? "dev" : "release",
platform: process.platform, platform: process.platform,
+1 -1
View File
@@ -57,7 +57,7 @@ export function updateTrayIssueIndicator(issueCount: number): void {
tray.setImage(createTrayIcon(hasIssues ? "danger" : "normal")); tray.setImage(createTrayIcon(hasIssues ? "danger" : "normal"));
tray.setTitle(hasIssues ? formatIssueCount(safeCount) : "", { fontType: "monospacedDigit" }); tray.setTitle(hasIssues ? formatIssueCount(safeCount) : "", { fontType: "monospacedDigit" });
tray.setToolTip(hasIssues ? `GEO Rankly Desktop · ${safeCount} 个账号健康问题` : "GEO Rankly Desktop"); tray.setToolTip(hasIssues ? `省心推 · ${safeCount} 个账号健康问题` : "省心推");
} }
export function initTray(onOpen: () => void): ElectronTray { export function initTray(onOpen: () => void): ElectronTray {
@@ -88,7 +88,7 @@ function openSettingsWindow() {
</div> </div>
<div> <div>
<p class="eyebrow">Workspace Cockpit</p> <p class="eyebrow">Workspace Cockpit</p>
<h1>GEO Rankly Desktop</h1> <h1>省心推</h1>
</div> </div>
</div> </div>
+1 -1
View File
@@ -11,7 +11,7 @@
rel="icon" rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%230f766e'/%3E%3Cstop offset='1' stop-color='%231b6cff'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='18' fill='%23f5f7fb'/%3E%3Ccircle cx='32' cy='32' r='18' fill='none' stroke='url(%23g)' stroke-width='6'/%3E%3Ccircle cx='32' cy='32' r='8' fill='url(%23g)'/%3E%3C/svg%3E" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%230f766e'/%3E%3Cstop offset='1' stop-color='%231b6cff'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='18' fill='%23f5f7fb'/%3E%3Ccircle cx='32' cy='32' r='18' fill='none' stroke='url(%23g)' stroke-width='6'/%3E%3Ccircle cx='32' cy='32' r='8' fill='url(%23g)'/%3E%3C/svg%3E"
/> />
<title>GEO Rankly Desktop</title> <title>省心推</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
@@ -105,7 +105,7 @@ onBeforeUnmount(() => {
<div class="brand-logo"> <div class="brand-logo">
<span class="brand-core"></span> <span class="brand-core"></span>
</div> </div>
<h1 class="brand-name">GEO Rankly</h1> <h1 class="brand-name">省心推</h1>
</header> </header>
<form class="login-form" @submit.prevent="submitLogin"> <form class="login-form" @submit.prevent="submitLogin">
-1
View File
@@ -210,7 +210,6 @@ runtime:
prompt_rule_scene_supplement_format: "适用场景:%s" prompt_rule_scene_supplement_format: "适用场景:%s"
prompt_rule_tone_supplement_format: "默认语气:%s" prompt_rule_tone_supplement_format: "默认语气:%s"
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右" prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
prompt_rule_supplement_heading: "补充要求:" prompt_rule_supplement_heading: "补充要求:"
prompt_rule_output_requirements_section: | prompt_rule_output_requirements_section: |
输出要求: 输出要求:
-1
View File
@@ -210,7 +210,6 @@ runtime:
prompt_rule_scene_supplement_format: "适用场景:%s" prompt_rule_scene_supplement_format: "适用场景:%s"
prompt_rule_tone_supplement_format: "默认语气:%s" prompt_rule_tone_supplement_format: "默认语气:%s"
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右" prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
prompt_rule_supplement_heading: "补充要求:" prompt_rule_supplement_heading: "补充要求:"
prompt_rule_output_requirements_section: | prompt_rule_output_requirements_section: |
输出要求: 输出要求:
+4 -4
View File
@@ -557,7 +557,7 @@ export interface ArticleListItem {
prompt_rule_id: number | null; prompt_rule_id: number | null;
prompt_rule_name: string | null; prompt_rule_name: string | null;
generation_mode: string | null; generation_mode: string | null;
platforms: string[]; auto_publish_platforms: string[];
generate_status: string; generate_status: string;
publish_status: string; publish_status: string;
title: string | null; title: string | null;
@@ -582,7 +582,7 @@ export interface ArticleDetail {
template_id: number | null; template_id: number | null;
template_name: string | null; template_name: string | null;
generation_mode: string | null; generation_mode: string | null;
platforms: string[]; auto_publish_platforms: string[];
cover_asset_url: string | null; cover_asset_url: string | null;
cover_image_asset_id: number | null; cover_image_asset_id: number | null;
generate_status: string; generate_status: string;
@@ -632,7 +632,6 @@ export interface GenerateImitationResponse {
export interface UpdateArticleRequest { export interface UpdateArticleRequest {
title: string; title: string;
markdown_content: string; markdown_content: string;
platforms?: string[];
cover_asset_url?: string | null; cover_asset_url?: string | null;
referenced_image_asset_ids?: number[]; referenced_image_asset_ids?: number[];
cover_image_asset_id?: number | null; cover_image_asset_id?: number | null;
@@ -1448,6 +1447,7 @@ export interface ScheduleTask {
cron_expr: string; cron_expr: string;
auto_publish: boolean; auto_publish: boolean;
publish_account_ids: string[]; publish_account_ids: string[];
auto_publish_platforms: string[];
cover_asset_url: string | null; cover_asset_url: string | null;
cover_image_asset_id: number | null; cover_image_asset_id: number | null;
enable_web_search: boolean; enable_web_search: boolean;
@@ -1534,7 +1534,7 @@ export interface InstantTaskItem {
name: string; name: string;
status: string; status: string;
execution_time: string | null; execution_time: string | null;
platforms: string[]; auto_publish_platforms: string[];
generate_count: number; generate_count: number;
created_at: string; created_at: string;
} }
-1
View File
@@ -210,7 +210,6 @@ runtime:
prompt_rule_scene_supplement_format: "适用场景:%s" prompt_rule_scene_supplement_format: "适用场景:%s"
prompt_rule_tone_supplement_format: "默认语气:%s" prompt_rule_tone_supplement_format: "默认语气:%s"
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右" prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
prompt_rule_supplement_heading: "补充要求:" prompt_rule_supplement_heading: "补充要求:"
prompt_rule_output_requirements_section: | prompt_rule_output_requirements_section: |
输出要求: 输出要求:
+2 -2
View File
@@ -123,7 +123,7 @@ func BuildOpenAPI(routes gin.RoutesInfo, cfg Config) map[string]any {
func withDefaults(cfg Config) Config { func withDefaults(cfg Config) Config {
if strings.TrimSpace(cfg.Title) == "" { if strings.TrimSpace(cfg.Title) == "" {
cfg.Title = "Geo Rankly Tenant API" cfg.Title = "省心推 客户 API"
} }
if strings.TrimSpace(cfg.Version) == "" { if strings.TrimSpace(cfg.Version) == "" {
cfg.Version = "dev" cfg.Version = "dev"
@@ -557,7 +557,7 @@ const swaggerIndexHTML = `<!doctype html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Geo Rankly API Swagger</title> <title>省心推 API Swagger</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" /> <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style> <style>
body { body {
+12 -6
View File
@@ -115,7 +115,7 @@ type ArticleListItem struct {
PromptRuleID *int64 `json:"prompt_rule_id"` PromptRuleID *int64 `json:"prompt_rule_id"`
PromptRuleName *string `json:"prompt_rule_name"` PromptRuleName *string `json:"prompt_rule_name"`
GenerationMode *string `json:"generation_mode"` GenerationMode *string `json:"generation_mode"`
Platforms []string `json:"platforms"` AutoPublishPlatforms []string `json:"auto_publish_platforms"`
GenerateStatus string `json:"generate_status"` GenerateStatus string `json:"generate_status"`
PublishStatus string `json:"publish_status"` PublishStatus string `json:"publish_status"`
Title *string `json:"title"` Title *string `json:"title"`
@@ -147,7 +147,7 @@ type ArticleDetailResponse struct {
TemplateID *int64 `json:"template_id"` TemplateID *int64 `json:"template_id"`
TemplateName *string `json:"template_name"` TemplateName *string `json:"template_name"`
GenerationMode *string `json:"generation_mode"` GenerationMode *string `json:"generation_mode"`
Platforms []string `json:"platforms"` AutoPublishPlatforms []string `json:"auto_publish_platforms"`
CoverAssetURL *string `json:"cover_asset_url"` CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"` CoverImageAssetID *int64 `json:"cover_image_asset_id"`
GenerateStatus string `json:"generate_status"` GenerateStatus string `json:"generate_status"`
@@ -355,7 +355,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
} }
item.SourceType = dbSourceType item.SourceType = dbSourceType
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON) item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON) autoPublishPlatforms, err := loadScheduleAutoPublishPlatforms(ctx, s.pool, tenantID, inputParamsJSON)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to resolve article auto publish platforms")
}
item.AutoPublishPlatforms = autoPublishPlatforms
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON) item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
items = append(items, item) items = append(items, item)
} }
@@ -407,7 +411,11 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
_ = json.Unmarshal(wizardStateJSON, &item.WizardState) _ = json.Unmarshal(wizardStateJSON, &item.WizardState)
} }
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON) item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON) autoPublishPlatforms, err := loadScheduleAutoPublishPlatforms(ctx, s.pool, tenantID, inputParamsJSON)
if err != nil {
return nil, false, response.ErrInternal(50010, "query_failed", "failed to resolve article auto publish platforms")
}
item.AutoPublishPlatforms = autoPublishPlatforms
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON) item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON) item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON) item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
@@ -495,7 +503,6 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (
type UpdateArticleRequest struct { type UpdateArticleRequest struct {
Title string `json:"title"` Title string `json:"title"`
MarkdownContent string `json:"markdown_content"` MarkdownContent string `json:"markdown_content"`
Platforms []string `json:"platforms"`
CoverAssetURL *string `json:"cover_asset_url"` CoverAssetURL *string `json:"cover_asset_url"`
ReferencedImageAssetIDs []int64 `json:"referenced_image_asset_ids"` ReferencedImageAssetIDs []int64 `json:"referenced_image_asset_ids"`
CoverImageAssetID NullableInt64Input `json:"cover_image_asset_id"` CoverImageAssetID NullableInt64Input `json:"cover_image_asset_id"`
@@ -577,7 +584,6 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
nextWizardStateJSON, err := mergeArticleWizardState( nextWizardStateJSON, err := mergeArticleWizardState(
wizardStateJSON, wizardStateJSON,
title, title,
req.Platforms,
req.CoverAssetURL, req.CoverAssetURL,
resolvedCoverImageAssetInput, resolvedCoverImageAssetInput,
) )
@@ -38,7 +38,7 @@ type InstantTaskResponse struct {
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
ExecutionTime *string `json:"execution_time"` ExecutionTime *string `json:"execution_time"`
Platforms []string `json:"platforms"` AutoPublishPlatforms []string `json:"auto_publish_platforms"`
GenerateCount int `json:"generate_count"` GenerateCount int `json:"generate_count"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
@@ -123,7 +123,11 @@ func (s *InstantTaskService) List(ctx context.Context, params InstantTaskListPar
item.ExecutionTime = stringPtrFromDBValue(executionAt) item.ExecutionTime = stringPtrFromDBValue(executionAt)
item.CreatedAt = fmt.Sprintf("%v", createdAt) item.CreatedAt = fmt.Sprintf("%v", createdAt)
item.Platforms = resolveArticlePlatforms(nil, inputParamsJSON) autoPublishPlatforms, err := loadScheduleAutoPublishPlatforms(ctx, s.pool, actor.TenantID, inputParamsJSON)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to resolve instant task auto publish platforms")
}
item.AutoPublishPlatforms = autoPublishPlatforms
item.GenerateCount = 1 item.GenerateCount = 1
if len(inputParamsJSON) > 0 { if len(inputParamsJSON) > 0 {
@@ -284,6 +284,7 @@ type monitoringAliasResolution struct {
type monitoringAliasLookupInput struct { type monitoringAliasLookupInput struct {
NormalizedURL string NormalizedURL string
MatchKeys []string
RegistrableDomain string RegistrableDomain string
SiteKey string SiteKey string
Host string Host string
@@ -293,6 +294,7 @@ type monitoringAliasLookupInput struct {
type monitoringAliasCandidate struct { type monitoringAliasCandidate struct {
NormalizedURL string NormalizedURL string
MatchKeys []string
ArticleID *int64 ArticleID *int64
PublishRecordID *int64 PublishRecordID *int64
RegistrableDomain string RegistrableDomain string
@@ -2444,6 +2446,7 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
} }
lookupInputs = append(lookupInputs, monitoringAliasLookupInput{ lookupInputs = append(lookupInputs, monitoringAliasLookupInput{
NormalizedURL: key, NormalizedURL: key,
MatchKeys: monitoringCitationCandidateKeys(item.URL, key),
RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)), RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)),
SiteKey: strings.ToLower(strings.TrimSpace(siteKey)), SiteKey: strings.ToLower(strings.TrimSpace(siteKey)),
Host: strings.ToLower(strings.TrimSpace(host)), Host: strings.ToLower(strings.TrimSpace(host)),
@@ -2557,6 +2560,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
candidate.Host = strings.ToLower(strings.TrimSpace(nullableStringText(host))) candidate.Host = strings.ToLower(strings.TrimSpace(nullableStringText(host)))
candidate.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment)) candidate.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment))
candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle)) candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle))
candidate.MatchKeys = monitoringCitationCandidateKeys(candidate.NormalizedURL, candidate.NormalizedURL)
candidates = append(candidates, candidate) candidates = append(candidates, candidate)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -2567,11 +2571,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
bestScore := -1 bestScore := -1
bestAmbiguous := false bestAmbiguous := false
var best monitoringAliasCandidate var best monitoringAliasCandidate
domainMatchedArticleIDs := map[int64]struct{}{}
for _, candidate := range candidates { for _, candidate := range candidates {
if monitoringAliasDomainMatches(input, candidate) && candidate.ArticleID != nil {
domainMatchedArticleIDs[*candidate.ArticleID] = struct{}{}
}
score := scoreMonitoringAliasCandidate(input, candidate) score := scoreMonitoringAliasCandidate(input, candidate)
if score < 0 { if score < 0 {
continue continue
@@ -2586,27 +2586,16 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
bestAmbiguous = true bestAmbiguous = true
} }
} }
if bestScore == 40 && len(domainMatchedArticleIDs) == 1 {
bestScore = 60
}
if bestScore < 60 || bestAmbiguous { if bestScore < 100 || bestAmbiguous {
continue continue
} }
confidence := "medium"
status := "domain_matched"
if bestScore >= 100 {
confidence = "high"
status = "resolved"
} else if bestScore < 80 {
confidence = "low"
}
result[input.NormalizedURL] = monitoringAliasResolution{ result[input.NormalizedURL] = monitoringAliasResolution{
ArticleID: cloneInt64(best.ArticleID), ArticleID: cloneInt64(best.ArticleID),
PublishRecordID: cloneInt64(best.PublishRecordID), PublishRecordID: cloneInt64(best.PublishRecordID),
NormalizedURLKey: best.NormalizedURL, NormalizedURLKey: best.NormalizedURL,
ResolutionStatus: status, ResolutionStatus: "resolved",
ResolutionConfidence: confidence, ResolutionConfidence: "high",
} }
} }
@@ -2618,23 +2607,19 @@ func scoreMonitoringAliasCandidate(input monitoringAliasLookupInput, candidate m
return 100 return 100
} }
if !monitoringAliasDomainMatches(input, candidate) { for _, inputKey := range input.MatchKeys {
return -1 inputKey = strings.TrimSpace(inputKey)
if inputKey == "" {
continue
}
for _, candidateKey := range candidate.MatchKeys {
if inputKey == strings.TrimSpace(candidateKey) && candidateKey != "" {
return 100
}
}
} }
score := 40 return -1
if input.LastPathSegment != nil && strings.TrimSpace(*input.LastPathSegment) != "" && candidate.LastPathSegment == strings.TrimSpace(*input.LastPathSegment) {
score += 30
}
if input.TitleKey != "" && candidate.TitleKey != "" {
switch {
case input.TitleKey == candidate.TitleKey:
score += 30
case strings.Contains(input.TitleKey, candidate.TitleKey) || strings.Contains(candidate.TitleKey, input.TitleKey):
score += 15
}
}
return score
} }
func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool { func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool {
@@ -425,23 +425,33 @@ func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) {
} }
} }
func TestScoreMonitoringAliasCandidateUsesDomainWithTitleEvidence(t *testing.T) { func TestScoreMonitoringAliasCandidateUsesCanonicalArticleKey(t *testing.T) {
lastPath := "article-42"
input := monitoringAliasLookupInput{ input := monitoringAliasLookupInput{
NormalizedURL: "https://m.example.com/article-42", NormalizedURL: "https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html",
RegistrableDomain: "example.com", MatchKeys: monitoringCitationCandidateKeys("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
LastPathSegment: &lastPath,
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
} }
candidate := monitoringAliasCandidate{ candidate := monitoringAliasCandidate{
NormalizedURL: "https://www.example.com/posts/article-42", NormalizedURL: "https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html",
RegistrableDomain: "example.com", MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
LastPathSegment: "article-42",
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
} }
if score := scoreMonitoringAliasCandidate(input, candidate); score < 90 { if score := scoreMonitoringAliasCandidate(input, candidate); score != 100 {
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want strong domain/title match", score) t.Fatalf("scoreMonitoringAliasCandidate() = %d, want canonical URL match", score)
}
}
func TestScoreMonitoringAliasCandidateRejectsSameDomainDifferentArticle(t *testing.T) {
input := monitoringAliasLookupInput{
NormalizedURL: "https://www.163.com/dy/article/KIN5DDMK0556GQZ8.html",
MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KIN5DDMK0556GQZ8.html", ""),
}
candidate := monitoringAliasCandidate{
NormalizedURL: "https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html",
MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
}
if score := scoreMonitoringAliasCandidate(input, candidate); score != -1 {
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want no same-domain match", score)
} }
} }
@@ -0,0 +1,251 @@
package app
import (
"context"
"database/sql"
"net"
"net/url"
"sort"
"strings"
"github.com/jackc/pgx/v5"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type monitoringPublishedAlias struct {
ArticleID int64
PublishRecordID *int64
ArticleTitle string
PublishPlatform string
Ambiguous bool
}
type monitoringPublishedAliasIndex map[string]monitoringPublishedAlias
type monitoringAliasQuerier interface {
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
}
func loadMonitoringPublishedAliasIndex(ctx context.Context, q monitoringAliasQuerier, tenantID int64) (monitoringPublishedAliasIndex, error) {
rows, err := q.Query(ctx, `
SELECT
article_id,
publish_record_id,
COALESCE(article_title_snapshot, '未命名文章') AS article_title,
COALESCE(publish_platform_name_snapshot, '已发布内容') AS publish_platform,
original_url,
normalized_url
FROM monitoring_article_url_aliases
WHERE tenant_id = $1
AND confidence = 'high'
ORDER BY updated_at DESC, id DESC
`, tenantID)
if err != nil {
return nil, responseInternalAliasLookupError()
}
defer rows.Close()
index := make(monitoringPublishedAliasIndex)
for rows.Next() {
var item monitoringPublishedAlias
var originalURL string
var normalizedURL string
var publishRecordID sql.NullInt64
if scanErr := rows.Scan(&item.ArticleID, &publishRecordID, &item.ArticleTitle, &item.PublishPlatform, &originalURL, &normalizedURL); scanErr != nil {
return nil, responseInternalAliasScanError()
}
item.PublishRecordID = nullableInt64Value(publishRecordID)
for _, key := range monitoringCitationCandidateKeys(originalURL, normalizedURL) {
existing, exists := index[key]
if exists && existing.ArticleID != item.ArticleID {
existing.Ambiguous = true
index[key] = existing
continue
}
if !exists {
index[key] = item
}
}
}
if err := rows.Err(); err != nil {
return nil, responseInternalAliasScanError()
}
return index, nil
}
func (index monitoringPublishedAliasIndex) Match(rawURL, normalizedURL string) (monitoringPublishedAlias, bool) {
for _, key := range monitoringCitationCandidateKeys(rawURL, normalizedURL) {
item, ok := index[key]
if ok && !item.Ambiguous {
return item, true
}
}
return monitoringPublishedAlias{}, false
}
func monitoringCitationCandidateKeys(rawURL, normalizedURL string) []string {
keys := []string{
monitoringCitationURLMatchKey(rawURL),
monitoringCitationURLMatchKey(normalizedURL),
}
result := make([]string, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, key)
}
return result
}
func monitoringCitationURLMatchKey(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Hostname() == "" {
return ""
}
host := monitoringCanonicalCitationHost(parsed.Hostname())
if host == "" {
return ""
}
if strings.HasSuffix(host, "163.com") {
if id := monitoringNeteaseArticleID(trimmed); id != "" {
return "163.com/dy/article/" + strings.ToLower(id)
}
}
if host == "baijiahao.baidu.com" {
if id := extractBaijiahaoArticleIDFromURL(&trimmed); id != "" {
return host + "/s?id=" + strings.ToLower(id)
}
}
path := strings.TrimRight(parsed.EscapedPath(), "/")
if path == "" {
path = "/"
}
if host == "mp.weixin.qq.com" {
if strings.HasPrefix(path, "/s/") && len(path) > len("/s/") {
return host + path
}
if query := monitoringCanonicalQuery(parsed, []string{"__biz", "mid", "idx", "sn"}); query != "" {
return host + path + "?" + query
}
return ""
}
if path == "/" {
if query := monitoringCanonicalQuery(parsed, monitoringGenericContentQueryKeys()); query != "" {
return host + path + "?" + query
}
return ""
}
if query := monitoringCanonicalQuery(parsed, monitoringGenericContentQueryKeys()); query != "" {
return host + path + "?" + query
}
return host + path
}
func monitoringCanonicalCitationHost(value string) string {
host := strings.ToLower(strings.TrimSpace(value))
if host == "" {
return ""
}
if withoutPort, _, err := net.SplitHostPort(host); err == nil {
host = withoutPort
}
host = strings.Trim(host, ".")
parts := strings.Split(host, ".")
if len(parts) <= 2 {
return host
}
switch parts[0] {
case "www", "m", "wap", "mobile", "3g", "amp":
return strings.Join(parts[1:], ".")
default:
return host
}
}
func monitoringCanonicalQuery(parsed *url.URL, allowedKeys []string) string {
if parsed == nil || len(allowedKeys) == 0 {
return ""
}
values := parsed.Query()
pairs := make([]string, 0, len(allowedKeys))
for _, key := range allowedKeys {
value := strings.TrimSpace(values.Get(key))
if value == "" {
continue
}
pairs = append(pairs, url.QueryEscape(key)+"="+url.QueryEscape(value))
}
sort.Strings(pairs)
return strings.Join(pairs, "&")
}
func monitoringGenericContentQueryKeys() []string {
return []string{"id", "article_id", "articleId", "docid", "docId", "postId"}
}
func monitoringNeteaseArticleID(rawURL string) string {
if id := extractWangyihaoArticleIDFromURL(&rawURL); id != "" {
return id
}
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return ""
}
segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for index := len(segments) - 1; index >= 0; index-- {
id := strings.TrimSuffix(strings.TrimSpace(segments[index]), ".html")
if monitoringLooksLikeNeteaseArticleID(id) {
return id
}
}
return ""
}
func monitoringLooksLikeNeteaseArticleID(value string) bool {
if len(value) < 8 || len(value) > 32 {
return false
}
hasDigit := false
hasLetter := false
for _, r := range value {
switch {
case r >= '0' && r <= '9':
hasDigit = true
case r >= 'A' && r <= 'Z':
hasLetter = true
case r >= 'a' && r <= 'z':
hasLetter = true
default:
return false
}
}
return hasDigit && hasLetter
}
func responseInternalAliasLookupError() error {
return response.ErrInternal(50041, "alias_lookup_failed", "failed to resolve article aliases")
}
func responseInternalAliasScanError() error {
return response.ErrInternal(50041, "alias_scan_failed", "failed to parse article alias resolution")
}
+225 -223
View File
@@ -414,29 +414,55 @@ func (s *MonitoringService) DashboardComposite(
func (s *MonitoringService) CitationSummary( func (s *MonitoringService) CitationSummary(
ctx context.Context, ctx context.Context,
days int, days int,
brandID int64,
keywordID *int64,
businessDate string,
aiPlatformID *string,
) (*MonitoringCitationSummaryResponse, error) { ) (*MonitoringCitationSummaryResponse, error) {
actor := auth.MustActor(ctx) actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx) workspaceID := auth.CurrentWorkspaceID(ctx)
var questionIDs []int64
if brandID != 0 {
brand, err := s.resolveBrand(ctx, actor.TenantID, brandID)
if err != nil {
return nil, err
}
if keywordID != nil {
if err := s.ensureKeywordBelongsToBrand(ctx, actor.TenantID, brand.ID, *keywordID); err != nil {
return nil, err
}
}
configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID)
if err != nil {
return nil, err
}
questionIDs = configuredQuestionIDs(configuredQuestions)
}
quota, err := s.loadQuota(ctx, actor, workspaceID) quota, err := s.loadQuota(ctx, actor, workspaceID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
citationWindowDays := normalizeCitationWindowDays(days) citationWindowDays := normalizeCitationWindowDays(days)
startDate, endDate := trackingDateWindow(citationWindowDays) startDate, endDate, err := resolveDashboardDateWindow(citationWindowDays, businessDate)
if err != nil {
return nil, err
}
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate) accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, 0, nil, startDate, endDate, accessStates, nil) selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID)
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, accessStates, selectedPlatformID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, 0, nil, startDate, endDate, nil) citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, selectedPlatformID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -2481,21 +2507,21 @@ func (s *MonitoringService) loadCitationRanking(
} }
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
type citationRankingAggregate struct {
CitedAnswerCount int64
CitedArticleCount int64
CitationSourceCount int64
SaaSSourceCount int64
SampleCount int64
}
aggregates := make(map[string]citationRankingAggregate)
rows, err := s.monitoringPool.Query(ctx, ` rows, err := s.monitoringPool.Query(ctx, `
WITH published_domains AS (
SELECT DISTINCT domain_key
FROM (
SELECT NULLIF(LOWER(TRIM(host)), '') AS domain_key
FROM monitoring_article_url_aliases
WHERE tenant_id = $1
UNION
SELECT NULLIF(LOWER(TRIM(site_key)), '') AS domain_key
FROM monitoring_article_url_aliases
WHERE tenant_id = $1
) domains
WHERE domain_key IS NOT NULL
),
run_counts AS (
SELECT SELECT
r.ai_platform_id, r.ai_platform_id,
COUNT(*) AS sample_count COUNT(*) AS sample_count
@@ -2508,25 +2534,38 @@ func (s *MonitoringService) loadCitationRanking(
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6)) AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7)) AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
GROUP BY r.ai_platform_id GROUP BY r.ai_platform_id
), `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
citation_sources AS ( if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
}
defer rows.Close()
for rows.Next() {
var platformID string
var sampleCount int64
if scanErr := rows.Scan(&platformID, &sampleCount); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
}
platformID = normalizeMonitoringPlatformID(platformID)
if platformID == "" {
continue
}
item := aggregates[platformID]
item.SampleCount += sampleCount
aggregates[platformID] = item
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking")
}
citedRuns := make(map[string]map[int64]struct{})
citedArticles := make(map[string]map[int64]struct{})
rows, err = s.monitoringPool.Query(ctx, `
SELECT SELECT
r.ai_platform_id, r.ai_platform_id,
cf.id,
cf.run_id, cf.run_id,
cf.article_id, cf.cited_url,
( cf.normalized_url
cf.article_id IS NOT NULL
OR EXISTS (
SELECT 1
FROM published_domains pd
WHERE pd.domain_key = ANY(ARRAY[
LOWER(NULLIF(cf.registrable_domain, '')),
LOWER(NULLIF(cf.site_key, '')),
LOWER(NULLIF(cf.host, ''))
])
)
) AS matched_saas_source
FROM question_monitor_runs r FROM question_monitor_runs r
JOIN monitoring_citation_facts cf JOIN monitoring_citation_facts cf
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
@@ -2537,60 +2576,18 @@ func (s *MonitoringService) loadCitationRanking(
AND r.business_date BETWEEN $4::date AND $5::date AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6)) AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7)) AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
),
citation_counts AS (
SELECT
ai_platform_id,
COUNT(*) AS citation_source_count,
COUNT(*) FILTER (WHERE matched_saas_source AND ai_platform_id <> 'qwen') AS saas_source_count,
COUNT(DISTINCT run_id) FILTER (WHERE matched_saas_source AND ai_platform_id <> 'qwen') AS cited_answer_count,
COUNT(DISTINCT article_id) FILTER (WHERE article_id IS NOT NULL AND ai_platform_id <> 'qwen') AS cited_article_count
FROM citation_sources
GROUP BY ai_platform_id
)
SELECT
rc.ai_platform_id,
rc.sample_count,
COALESCE(cc.cited_answer_count, 0) AS cited_answer_count,
COALESCE(cc.cited_article_count, 0) AS cited_article_count,
COALESCE(cc.citation_source_count, 0) AS citation_source_count,
COALESCE(cc.saas_source_count, 0) AS saas_source_count,
CASE
WHEN COALESCE(cc.citation_source_count, 0) > 0
THEN COALESCE(cc.saas_source_count, 0)::double precision / cc.citation_source_count::double precision
ELSE NULL
END AS saas_source_rate,
CASE
WHEN rc.sample_count > 0 THEN COALESCE(cc.cited_answer_count, 0)::double precision / rc.sample_count
ELSE NULL
END AS citation_rate
FROM run_counts rc
LEFT JOIN citation_counts cc ON cc.ai_platform_id = rc.ai_platform_id
WHERE COALESCE(cc.saas_source_count, 0) > 0
ORDER BY cited_answer_count DESC, saas_source_count DESC, cited_article_count DESC, rc.ai_platform_id ASC
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)) `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
if err != nil { if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking") return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
} }
defer rows.Close() defer rows.Close()
type citationRankingAggregate struct {
CitedAnswerCount int64
CitedArticleCount int64
CitationSourceCount int64
SaaSSourceCount int64
SampleCount int64
}
aggregates := make(map[string]citationRankingAggregate)
for rows.Next() { for rows.Next() {
var platformID string var platformID string
var sampleCount int64 var runID int64
var citedAnswerCount int64 var citedURL string
var citedArticleCount int64 var normalizedURL string
var citationSourceCount int64 if scanErr := rows.Scan(&platformID, &runID, &citedURL, &normalizedURL); scanErr != nil {
var saasSourceCount int64
if scanErr := rows.Scan(&platformID, &sampleCount, &citedAnswerCount, &citedArticleCount, &citationSourceCount, &saasSourceCount, new(sql.NullFloat64), new(sql.NullFloat64)); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking") return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
} }
platformID = normalizeMonitoringPlatformID(platformID) platformID = normalizeMonitoringPlatformID(platformID)
@@ -2598,16 +2595,36 @@ func (s *MonitoringService) loadCitationRanking(
continue continue
} }
item := aggregates[platformID] item := aggregates[platformID]
item.CitedAnswerCount += citedAnswerCount item.CitationSourceCount++
item.CitedArticleCount += citedArticleCount if platformID != "qwen" {
item.CitationSourceCount += citationSourceCount if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
item.SaaSSourceCount += saasSourceCount item.SaaSSourceCount++
item.SampleCount += sampleCount if _, exists := citedRuns[platformID]; !exists {
citedRuns[platformID] = make(map[int64]struct{})
}
citedRuns[platformID][runID] = struct{}{}
if _, exists := citedArticles[platformID]; !exists {
citedArticles[platformID] = make(map[int64]struct{})
}
citedArticles[platformID][alias.ArticleID] = struct{}{}
}
}
aggregates[platformID] = item
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking")
}
for platformID, item := range aggregates {
item.CitedAnswerCount = int64(len(citedRuns[platformID]))
item.CitedArticleCount = int64(len(citedArticles[platformID]))
aggregates[platformID] = item aggregates[platformID] = item
} }
items := make([]MonitoringCitationRanking, 0, len(aggregates)) items := make([]MonitoringCitationRanking, 0, len(aggregates))
for platformID, aggregate := range aggregates { for platformID, aggregate := range aggregates {
if aggregate.SaaSSourceCount == 0 {
continue
}
items = append(items, MonitoringCitationRanking{ items = append(items, MonitoringCitationRanking{
AIPlatformID: platformID, AIPlatformID: platformID,
PlatformName: platformDisplayName(platformID), PlatformName: platformDisplayName(platformID),
@@ -2648,6 +2665,11 @@ func (s *MonitoringService) loadCitedArticles(
} }
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
var totalSampleCount int64 var totalSampleCount int64
if err := s.monitoringPool.QueryRow(ctx, ` if err := s.monitoringPool.QueryRow(ctx, `
SELECT COUNT(*) SELECT COUNT(*)
@@ -2664,78 +2686,78 @@ func (s *MonitoringService) loadCitedArticles(
} }
rows, err := s.monitoringPool.Query(ctx, ` rows, err := s.monitoringPool.Query(ctx, `
WITH article_counts AS (
SELECT SELECT
cf.article_id, cf.cited_url,
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title, cf.normalized_url
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
COUNT(*) AS citation_count
FROM monitoring_citation_facts cf FROM monitoring_citation_facts cf
JOIN question_monitor_runs r JOIN question_monitor_runs r
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
LEFT JOIN LATERAL (
SELECT
alias.article_title_snapshot,
alias.publish_platform_name_snapshot
FROM monitoring_article_url_aliases alias
WHERE alias.tenant_id = cf.tenant_id
AND alias.article_id = cf.article_id
ORDER BY
(alias.publish_record_id = cf.publish_record_id) DESC NULLS LAST,
alias.updated_at DESC,
alias.id DESC
LIMIT 1
) alias ON TRUE
WHERE cf.tenant_id = $1 WHERE cf.tenant_id = $1
AND ($2::bigint = 0 OR cf.brand_id = $2) AND ($2::bigint = 0 OR cf.brand_id = $2)
AND cf.article_id IS NOT NULL
AND r.ai_platform_id <> 'qwen' AND r.ai_platform_id <> 'qwen'
AND r.collector_type = $3 AND r.collector_type = $3
AND r.status = 'succeeded' AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6)) AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7)) AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
GROUP BY cf.article_id
),
totals AS (
SELECT COALESCE(SUM(citation_count), 0) AS total_article_citation_count
FROM article_counts
)
SELECT
cf.article_id,
cf.article_title,
cf.publish_platform,
cf.citation_count,
t.total_article_citation_count
FROM article_counts cf
CROSS JOIN totals t
ORDER BY citation_count DESC, cf.article_id ASC
LIMIT 10
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)) `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
if err != nil { if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles") return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles")
} }
defer rows.Close() defer rows.Close()
items := make([]MonitoringCitedArticle, 0) type citedArticleAggregate struct {
for rows.Next() { ArticleID int64
var articleID int64 ArticleTitle string
var title string PublishPlatform string
var publishPlatform string CitationCount int64
var citationCount int64 }
aggregates := make(map[int64]citedArticleAggregate)
var totalArticleCitationCount int64 var totalArticleCitationCount int64
if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount, &totalArticleCitationCount); scanErr != nil { for rows.Next() {
var citedURL string
var normalizedURL string
if scanErr := rows.Scan(&citedURL, &normalizedURL); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles") return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
} }
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
if !ok {
continue
}
item := aggregates[alias.ArticleID]
if item.ArticleID == 0 {
item.ArticleID = alias.ArticleID
item.ArticleTitle = alias.ArticleTitle
item.PublishPlatform = alias.PublishPlatform
}
item.CitationCount++
totalArticleCitationCount++
aggregates[alias.ArticleID] = item
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles")
}
items := make([]MonitoringCitedArticle, 0, len(aggregates))
for _, item := range aggregates {
items = append(items, MonitoringCitedArticle{ items = append(items, MonitoringCitedArticle{
ArticleID: articleID, ArticleID: item.ArticleID,
ArticleTitle: title, ArticleTitle: item.ArticleTitle,
PublishPlatform: publishPlatform, PublishPlatform: item.PublishPlatform,
CitationCount: citationCount, CitationCount: item.CitationCount,
CitationRate: divideAsPointer(citationCount, totalSampleCount), CitationRate: divideAsPointer(item.CitationCount, totalSampleCount),
SourceShare: divideAsPointer(citationCount, totalArticleCitationCount), SourceShare: divideAsPointer(item.CitationCount, totalArticleCitationCount),
}) })
} }
sort.Slice(items, func(left, right int) bool {
if items[left].CitationCount != items[right].CitationCount {
return items[left].CitationCount > items[right].CitationCount
}
return items[left].ArticleID < items[right].ArticleID
})
if len(items) > 10 {
items = items[:10]
}
return items, nil return items, nil
} }
@@ -2923,6 +2945,11 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
return result, nil return result, nil
} }
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
rows, err := s.monitoringPool.Query(ctx, ` rows, err := s.monitoringPool.Query(ctx, `
WITH filtered_citations AS ( WITH filtered_citations AS (
SELECT SELECT
@@ -2930,10 +2957,10 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
cf.run_id, cf.run_id,
cf.cited_url, cf.cited_url,
cf.cited_title, cf.cited_title,
cf.normalized_url,
cf.host, cf.host,
cf.registrable_domain, cf.registrable_domain,
cf.site_key, cf.site_key,
cf.article_id,
cf.resolution_status, cf.resolution_status,
cf.resolution_confidence cf.resolution_confidence
FROM monitoring_citation_facts cf FROM monitoring_citation_facts cf
@@ -2979,10 +3006,9 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
fc.run_id, fc.run_id,
fc.cited_url, fc.cited_url,
fc.cited_title, fc.cited_title,
fc.normalized_url,
COALESCE(dm.mapped_site_name, fc.site_key, fc.registrable_domain) AS site_name, COALESCE(dm.mapped_site_name, fc.site_key, fc.registrable_domain) AS site_name,
COALESCE(dm.mapped_site_key, fc.site_key) AS site_key, COALESCE(dm.mapped_site_key, fc.site_key) AS site_key,
fc.article_id,
alias.article_title_snapshot AS article_title,
fc.resolution_status, fc.resolution_status,
fc.resolution_confidence fc.resolution_confidence
FROM filtered_citations fc FROM filtered_citations fc
@@ -2990,8 +3016,6 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
ON dm.host = fc.host ON dm.host = fc.host
AND dm.registrable_domain = fc.registrable_domain AND dm.registrable_domain = fc.registrable_domain
AND dm.site_key = fc.site_key AND dm.site_key = fc.site_key
LEFT JOIN monitoring_article_url_aliases alias
ON alias.tenant_id = $1 AND alias.article_id = fc.article_id
ORDER BY fc.run_id ASC, fc.id ASC ORDER BY fc.run_id ASC, fc.id ASC
`, tenantID, runIDs) `, tenantID, runIDs)
if err != nil { if err != nil {
@@ -3003,21 +3027,26 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
var runID int64 var runID int64
var citedURL string var citedURL string
var citedTitle sql.NullString var citedTitle sql.NullString
var normalizedURL string
var siteName string var siteName string
var siteKey string var siteKey string
var articleID sql.NullInt64
var articleTitle sql.NullString
var resolutionStatus string var resolutionStatus string
var resolutionConfidence string var resolutionConfidence string
if scanErr := rows.Scan(&runID, &citedURL, &citedTitle, &siteName, &siteKey, &articleID, &articleTitle, &resolutionStatus, &resolutionConfidence); scanErr != nil { if scanErr := rows.Scan(&runID, &citedURL, &citedTitle, &normalizedURL, &siteName, &siteKey, &resolutionStatus, &resolutionConfidence); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citations") return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citations")
} }
siteName = repairMonitoringMojibake(siteName) siteName = repairMonitoringMojibake(siteName)
if citedTitle.Valid { if citedTitle.Valid {
citedTitle.String = repairMonitoringMojibake(citedTitle.String) citedTitle.String = repairMonitoringMojibake(citedTitle.String)
} }
if articleTitle.Valid { var articleID *int64
articleTitle.String = repairMonitoringMojibake(articleTitle.String) var articleTitle *string
if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
articleID = &alias.ArticleID
title := repairMonitoringMojibake(alias.ArticleTitle)
articleTitle = &title
resolutionStatus = "resolved"
resolutionConfidence = "high"
} }
result[runID] = append(result[runID], MonitoringQuestionDetailCitation{ result[runID] = append(result[runID], MonitoringQuestionDetailCitation{
CitedURL: citedURL, CitedURL: citedURL,
@@ -3025,12 +3054,15 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
SiteName: siteName, SiteName: siteName,
SiteKey: siteKey, SiteKey: siteKey,
FaviconURL: faviconURL(siteKey), FaviconURL: faviconURL(siteKey),
ArticleID: nullableInt64Value(articleID), ArticleID: articleID,
ArticleTitle: nullableStringValue(articleTitle), ArticleTitle: articleTitle,
ResolutionStatus: resolutionStatus, ResolutionStatus: resolutionStatus,
ResolutionConfidence: resolutionConfidence, ResolutionConfidence: resolutionConfidence,
}) })
} }
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citations")
}
return result, nil return result, nil
} }
@@ -3040,15 +3072,21 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
return []MonitoringQuestionCitationStats{}, nil return []MonitoringQuestionCitationStats{}, nil
} }
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
rows, err := s.monitoringPool.Query(ctx, ` rows, err := s.monitoringPool.Query(ctx, `
WITH filtered_facts AS ( WITH filtered_facts AS (
SELECT SELECT
r.ai_platform_id, r.ai_platform_id,
cf.id, cf.id,
cf.cited_url,
cf.normalized_url,
cf.host, cf.host,
cf.registrable_domain, cf.registrable_domain,
cf.site_key, cf.site_key
cf.article_id
FROM question_monitor_runs r FROM question_monitor_runs r
JOIN monitoring_citation_facts cf JOIN monitoring_citation_facts cf
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
@@ -3090,49 +3128,20 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
ORDER BY LENGTH(mapping.registrable_domain) DESC, mapping.id DESC ORDER BY LENGTH(mapping.registrable_domain) DESC, mapping.id DESC
LIMIT 1 LIMIT 1
) dm ON TRUE ) dm ON TRUE
), )
grouped AS (
SELECT SELECT
ff.ai_platform_id, ff.ai_platform_id,
ff.cited_url,
ff.normalized_url,
COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain) AS site_name, COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain) AS site_name,
COALESCE(dm.mapped_site_key, ff.site_key) AS site_key, COALESCE(dm.mapped_site_key, ff.site_key) AS site_key,
COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain, COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain
COUNT(ff.id) AS citation_count,
COUNT(*) FILTER (WHERE ff.article_id IS NOT NULL AND ff.ai_platform_id <> 'qwen') AS content_citation_count
FROM filtered_facts ff FROM filtered_facts ff
LEFT JOIN domain_mappings dm LEFT JOIN domain_mappings dm
ON dm.host = ff.host ON dm.host = ff.host
AND dm.registrable_domain = ff.registrable_domain AND dm.registrable_domain = ff.registrable_domain
AND dm.site_key = ff.site_key AND dm.site_key = ff.site_key
GROUP BY ORDER BY ff.ai_platform_id ASC, ff.id ASC
ff.ai_platform_id,
COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain),
COALESCE(dm.mapped_site_key, ff.site_key),
COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain)
),
totals AS (
SELECT
ai_platform_id,
SUM(citation_count) AS total_citation_count
FROM grouped
GROUP BY ai_platform_id
)
SELECT
g.ai_platform_id,
g.site_name,
g.site_key,
g.site_domain,
g.citation_count,
CASE
WHEN t.total_citation_count > 0
THEN g.citation_count::double precision / t.total_citation_count::double precision
ELSE NULL
END AS citation_rate,
g.content_citation_count
FROM grouped g
JOIN totals t
ON t.ai_platform_id = g.ai_platform_id
ORDER BY g.ai_platform_id ASC, g.citation_count DESC, g.site_name ASC
`, tenantID, runIDs) `, tenantID, runIDs)
if err != nil { if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis") return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis")
@@ -3148,12 +3157,12 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
totals := make(map[string]int64) totals := make(map[string]int64)
for rows.Next() { for rows.Next() {
var platformID string var platformID string
var citedURL string
var normalizedURL string
var siteName string var siteName string
var siteKey string var siteKey string
var siteDomain string var siteDomain string
var citationCount int64 if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL, &siteName, &siteKey, &siteDomain); scanErr != nil {
var contentCitationCount int64
if scanErr := rows.Scan(&platformID, &siteName, &siteKey, &siteDomain, &citationCount, new(sql.NullFloat64), &contentCitationCount); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis") return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis")
} }
platformID = normalizeMonitoringPlatformID(platformID) platformID = normalizeMonitoringPlatformID(platformID)
@@ -3174,10 +3183,17 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
SiteDomain: siteDomain, SiteDomain: siteDomain,
} }
} }
item.CitationCount += citationCount item.CitationCount++
item.ContentCitationCount += contentCitationCount if platformID != "qwen" {
if _, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
item.ContentCitationCount++
}
}
aggregates[key] = item aggregates[key] = item
totals[platformID] += citationCount totals[platformID]++
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate question citation analysis")
} }
items := make([]MonitoringQuestionCitationStats, 0, len(aggregates)) items := make([]MonitoringQuestionCitationStats, 0, len(aggregates))
@@ -3206,40 +3222,23 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
return []MonitoringQuestionContentCitation{}, nil return []MonitoringQuestionContentCitation{}, nil
} }
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
rows, err := s.monitoringPool.Query(ctx, ` rows, err := s.monitoringPool.Query(ctx, `
WITH platform_totals AS (
SELECT SELECT
r.ai_platform_id, r.ai_platform_id,
COUNT(cf.id) AS total_citation_count cf.cited_url,
cf.normalized_url
FROM question_monitor_runs r FROM question_monitor_runs r
JOIN monitoring_citation_facts cf JOIN monitoring_citation_facts cf
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
WHERE r.tenant_id = $1 WHERE r.tenant_id = $1
AND r.status = 'succeeded' AND r.status = 'succeeded'
AND r.id = ANY($2)
GROUP BY r.ai_platform_id
)
SELECT
cf.article_id,
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
r.ai_platform_id,
COUNT(*) AS citation_count,
MAX(pt.total_citation_count) AS total_citation_count
FROM question_monitor_runs r
JOIN monitoring_citation_facts cf
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
JOIN platform_totals pt
ON pt.ai_platform_id = r.ai_platform_id
LEFT JOIN monitoring_article_url_aliases alias
ON alias.tenant_id = cf.tenant_id AND alias.article_id = cf.article_id
WHERE r.tenant_id = $1
AND r.status = 'succeeded'
AND cf.article_id IS NOT NULL
AND r.ai_platform_id <> 'qwen' AND r.ai_platform_id <> 'qwen'
AND r.id = ANY($2) AND r.id = ANY($2)
GROUP BY cf.article_id, r.ai_platform_id
ORDER BY citation_count DESC, cf.article_id ASC
`, tenantID, runIDs) `, tenantID, runIDs)
if err != nil { if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations") return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations")
@@ -3254,37 +3253,40 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
aggregates := make(map[contentCitationKey]MonitoringQuestionContentCitation) aggregates := make(map[contentCitationKey]MonitoringQuestionContentCitation)
totals := make(map[string]int64) totals := make(map[string]int64)
for rows.Next() { for rows.Next() {
var articleID int64
var articleTitle string
var publishPlatform string
var platformID string var platformID string
var citationCount int64 var citedURL string
var totalCitationCount int64 var normalizedURL string
if scanErr := rows.Scan(&articleID, &articleTitle, &publishPlatform, &platformID, &citationCount, &totalCitationCount); scanErr != nil { if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations") return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations")
} }
platformID = normalizeMonitoringPlatformID(platformID) platformID = normalizeMonitoringPlatformID(platformID)
if platformID == "" { if platformID == "" {
continue continue
} }
totals[platformID]++
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
if !ok {
continue
}
key := contentCitationKey{ key := contentCitationKey{
PlatformID: platformID, PlatformID: platformID,
ArticleID: articleID, ArticleID: alias.ArticleID,
} }
item := aggregates[key] item := aggregates[key]
if item.ArticleID == 0 { if item.ArticleID == 0 {
item = MonitoringQuestionContentCitation{ item = MonitoringQuestionContentCitation{
ArticleID: articleID, ArticleID: alias.ArticleID,
ArticleTitle: articleTitle, ArticleTitle: alias.ArticleTitle,
PublishPlatform: publishPlatform, PublishPlatform: alias.PublishPlatform,
AIPlatformID: platformID, AIPlatformID: platformID,
PlatformName: platformDisplayName(platformID), PlatformName: platformDisplayName(platformID),
} }
} }
item.CitationCount += citationCount item.CitationCount++
aggregates[key] = item aggregates[key] = item
totals[platformID] += citationCount }
_ = totalCitationCount if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate content citations")
} }
items := make([]MonitoringQuestionContentCitation, 0, len(aggregates)) items := make([]MonitoringQuestionContentCitation, 0, len(aggregates))
@@ -343,7 +343,7 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
coverImageAssetID.Set = true coverImageAssetID.Set = true
coverImageAssetID.Value = &rawCoverImageAssetID coverImageAssetID.Value = &rawCoverImageAssetID
} }
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, nil, coverAssetURL, coverImageAssetID) wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, coverAssetURL, coverImageAssetID)
tx, err := s.pool.Begin(ctx) tx, err := s.pool.Begin(ctx)
if err != nil { if err != nil {
+71 -71
View File
@@ -1,9 +1,12 @@
package app package app
import ( import (
"context"
"encoding/json" "encoding/json"
"strconv" "strconv"
"strings" "strings"
"github.com/jackc/pgx/v5"
) )
func normalizePlatformIDs(platformIDs []string) []string { func normalizePlatformIDs(platformIDs []string) []string {
@@ -31,52 +34,79 @@ func normalizePlatformIDs(platformIDs []string) []string {
return normalized return normalized
} }
func parsePlatformIDs(value string) []string { type scheduleAutoPublishPlatformQuerier interface {
if strings.TrimSpace(value) == "" { Query(context.Context, string, ...interface{}) (pgx.Rows, error)
return nil
}
return normalizePlatformIDs(strings.Split(value, ","))
} }
func serializePlatformIDs(platformIDs []string) string { func loadScheduleAutoPublishPlatforms(
normalized := normalizePlatformIDs(platformIDs) ctx context.Context,
if len(normalized) == 0 { db scheduleAutoPublishPlatformQuerier,
return "" tenantID int64,
} inputParamsJSON []byte,
return strings.Join(normalized, ",") ) ([]string, error) {
} if db == nil || tenantID <= 0 || len(inputParamsJSON) == 0 {
return []string{}, nil
func resolveArticlePlatforms(wizardStateJSON, inputParamsJSON []byte) []string {
if platforms := extractPlatformsFromJSONPayload(wizardStateJSON); len(platforms) > 0 {
return platforms
}
if platforms := extractPlatformsFromJSONPayload(inputParamsJSON); len(platforms) > 0 {
return platforms
}
return []string{}
}
func extractPlatformsFromJSONPayload(raw []byte) []string {
if len(raw) == 0 {
return nil
} }
var payload map[string]interface{} var payload map[string]interface{}
if err := json.Unmarshal(raw, &payload); err != nil { if err := json.Unmarshal(inputParamsJSON, &payload); err != nil {
return nil return []string{}, nil
}
if !extractBool(payload, "schedule_auto_publish") {
return []string{}, nil
} }
if platforms := normalizePlatformsFromValue(payload["platforms"]); len(platforms) > 0 { accountIDs := normalizeSchedulePublishAccountIDs(extractStringList(payload["schedule_publish_account_ids"], 64))
return platforms if len(accountIDs) == 0 {
} return []string{}, nil
if platforms := normalizePlatformsFromValue(payload["target_platforms"]); len(platforms) > 0 {
return platforms
} }
if targetPlatform, ok := payload["target_platform"].(string); ok { return loadPublishAccountPlatformsByIDs(ctx, db, tenantID, accountIDs)
return parsePlatformIDs(targetPlatform)
} }
return nil
func loadPublishAccountPlatformsByIDs(
ctx context.Context,
db scheduleAutoPublishPlatformQuerier,
tenantID int64,
accountIDs []string,
) ([]string, error) {
accountIDs = normalizeSchedulePublishAccountIDs(accountIDs)
if db == nil || tenantID <= 0 || len(accountIDs) == 0 {
return []string{}, nil
}
rows, err := db.Query(ctx, `
SELECT DISTINCT platform_id
FROM platform_accounts
WHERE tenant_id = $1
AND desktop_id::text = ANY($2::text[])
AND deleted_at IS NULL
ORDER BY platform_id
`, tenantID, accountIDs)
if err != nil {
return nil, err
}
defer rows.Close()
platformIDs := make([]string, 0, len(accountIDs))
for rows.Next() {
var platformID string
if err := rows.Scan(&platformID); err != nil {
return nil, err
}
platformIDs = append(platformIDs, platformID)
}
if err := rows.Err(); err != nil {
return nil, err
}
return emptyStringSliceIfNil(normalizePlatformIDs(platformIDs)), nil
}
func emptyStringSliceIfNil(values []string) []string {
if values == nil {
return []string{}
}
return values
} }
func resolveArticleCoverAssetURL(wizardStateJSON []byte) *string { func resolveArticleCoverAssetURL(wizardStateJSON []byte) *string {
@@ -146,51 +176,21 @@ func extractInt64FromJSONPayload(raw []byte, key string) (int64, bool) {
} }
} }
func normalizePlatformsFromValue(raw interface{}) []string { func mergeArticleWizardState(raw []byte, title string, coverAssetURL *string, coverImageAssetID NullableInt64Input) ([]byte, error) {
switch typed := raw.(type) {
case nil:
return nil
case string:
return parsePlatformIDs(typed)
case []string:
return normalizePlatformIDs(typed)
case []interface{}:
values := make([]string, 0, len(typed))
for _, item := range typed {
value, ok := item.(string)
if !ok {
continue
}
values = append(values, value)
}
return normalizePlatformIDs(values)
default:
return nil
}
}
func mergeArticleWizardState(raw []byte, title string, platforms []string, coverAssetURL *string, coverImageAssetID NullableInt64Input) ([]byte, error) {
state := make(map[string]interface{}) state := make(map[string]interface{})
if len(raw) > 0 { if len(raw) > 0 {
_ = json.Unmarshal(raw, &state) _ = json.Unmarshal(raw, &state)
} }
delete(state, "platforms")
delete(state, "target_platforms")
delete(state, "target_platform")
trimmedTitle := strings.TrimSpace(title) trimmedTitle := strings.TrimSpace(title)
if trimmedTitle != "" { if trimmedTitle != "" {
state["title"] = trimmedTitle state["title"] = trimmedTitle
} }
if platforms != nil {
normalized := normalizePlatformIDs(platforms)
if len(normalized) == 0 {
delete(state, "platforms")
delete(state, "target_platform")
} else {
state["platforms"] = normalized
state["target_platform"] = strings.Join(normalized, ",")
}
}
if coverAssetURL != nil { if coverAssetURL != nil {
trimmedCoverURL := strings.TrimSpace(*coverAssetURL) trimmedCoverURL := strings.TrimSpace(*coverAssetURL)
if trimmedCoverURL == "" { if trimmedCoverURL == "" {
@@ -106,6 +106,16 @@ func TestMonitoringAliasOriginalURLUsesWangyihaoPublicURL(t *testing.T) {
} }
} }
func TestMonitoringCitationURLMatchKeyCollapsesNeteaseHostPrefix(t *testing.T) {
left := monitoringCitationURLMatchKey("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html?from=test")
right := monitoringCitationURLMatchKey("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html")
const want = "163.com/dy/article/krpre8dj0556mcr0"
if left != want || right != want {
t.Fatalf("monitoringCitationURLMatchKey() = %q and %q; want %q", left, right, want)
}
}
func TestNormalizePublishStatusKeepsPartialSuccess(t *testing.T) { func TestNormalizePublishStatusKeepsPartialSuccess(t *testing.T) {
if got := normalizePublishStatus("partial_success"); got != "partial_success" { if got := normalizePublishStatus("partial_success"); got != "partial_success" {
t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got) t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got)
@@ -64,6 +64,7 @@ type ScheduleTaskResponse struct {
CronExpr string `json:"cron_expr"` CronExpr string `json:"cron_expr"`
AutoPublish bool `json:"auto_publish"` AutoPublish bool `json:"auto_publish"`
PublishAccountIDs []string `json:"publish_account_ids"` PublishAccountIDs []string `json:"publish_account_ids"`
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
CoverAssetURL *string `json:"cover_asset_url"` CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"` CoverImageAssetID *int64 `json:"cover_image_asset_id"`
EnableWebSearch bool `json:"enable_web_search"` EnableWebSearch bool `json:"enable_web_search"`
@@ -206,7 +207,7 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
return &ScheduleTaskResponse{ return &ScheduleTaskResponse{
ID: id, WorkspaceID: &workspaceID, PromptRuleID: req.PromptRuleID, BrandID: req.BrandID, ID: id, WorkspaceID: &workspaceID, PromptRuleID: req.PromptRuleID, BrandID: req.BrandID,
Name: req.Name, CronExpr: req.CronExpr, AutoPublish: publishConfig.AutoPublish, Name: req.Name, CronExpr: req.CronExpr, AutoPublish: publishConfig.AutoPublish,
PublishAccountIDs: publishConfig.AccountIDs, CoverAssetURL: publishConfig.CoverAssetURL, PublishAccountIDs: publishConfig.AccountIDs, AutoPublishPlatforms: publishConfig.PlatformIDs, CoverAssetURL: publishConfig.CoverAssetURL,
CoverImageAssetID: publishConfig.CoverImageAssetID, EnableWebSearch: enableWebSearch, CoverImageAssetID: publishConfig.CoverImageAssetID, EnableWebSearch: enableWebSearch,
GenerateCount: generateCount, GenerateCount: generateCount,
Status: status, CreatedAt: fmt.Sprintf("%v", createdAt), Status: status, CreatedAt: fmt.Sprintf("%v", createdAt),
@@ -400,6 +401,9 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID in
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := s.populateScheduleAutoPublishPlatforms(ctx, tenantID, &item); err != nil {
return nil, err
}
items = append(items, item) items = append(items, item)
} }
return &ScheduleTaskListResponse{Items: items, Total: total}, nil return &ScheduleTaskListResponse{Items: items, Total: total}, nil
@@ -425,9 +429,24 @@ func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenant
} }
return nil, false, err return nil, false, err
} }
if err := s.populateScheduleAutoPublishPlatforms(ctx, tenantID, &item); err != nil {
return nil, false, err
}
return &item, true, nil return &item, true, nil
} }
func (s *ScheduleTaskService) populateScheduleAutoPublishPlatforms(ctx context.Context, tenantID int64, item *ScheduleTaskResponse) error {
if item == nil || !item.AutoPublish {
return nil
}
platformIDs, err := loadPublishAccountPlatformsByIDs(ctx, s.pool, tenantID, item.PublishAccountIDs)
if err != nil {
return response.ErrInternal(50010, "query_failed", "failed to resolve schedule auto publish platforms")
}
item.AutoPublishPlatforms = platformIDs
return nil
}
func scanScheduleTaskResponse(scanner interface { func scanScheduleTaskResponse(scanner interface {
Scan(dest ...interface{}) error Scan(dest ...interface{}) error
}) (ScheduleTaskResponse, error) { }) (ScheduleTaskResponse, error) {
@@ -480,6 +499,7 @@ func (s *ScheduleTaskService) ensurePromptRuleExists(ctx context.Context, tenant
type scheduleAutoPublishConfig struct { type scheduleAutoPublishConfig struct {
AutoPublish bool AutoPublish bool
AccountIDs []string AccountIDs []string
PlatformIDs []string
AccountIDsJSON []byte AccountIDsJSON []byte
CoverAssetURL *string CoverAssetURL *string
CoverImageAssetID *int64 CoverImageAssetID *int64
@@ -514,6 +534,7 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
if err != nil { if err != nil {
return config, err return config, err
} }
config.PlatformIDs = normalizePlatformIDs(platformIDs)
if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" { if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" {
return config, response.ErrBadRequest(40025, "cover_required", "selected publish accounts require cover_asset_url") return config, response.ErrBadRequest(40025, "cover_required", "selected publish accounts require cover_asset_url")
} }
-1
View File
@@ -45,7 +45,6 @@ type runtimePromptsConfig struct {
PromptRuleSceneSupplementFormat string `yaml:"prompt_rule_scene_supplement_format"` PromptRuleSceneSupplementFormat string `yaml:"prompt_rule_scene_supplement_format"`
PromptRuleToneSupplementFormat string `yaml:"prompt_rule_tone_supplement_format"` PromptRuleToneSupplementFormat string `yaml:"prompt_rule_tone_supplement_format"`
PromptRuleWordCountSupplementFormat string `yaml:"prompt_rule_word_count_supplement_format"` PromptRuleWordCountSupplementFormat string `yaml:"prompt_rule_word_count_supplement_format"`
PromptRuleTargetPlatformSupplementFormat string `yaml:"prompt_rule_target_platform_supplement_format"`
PromptRuleSupplementHeading string `yaml:"prompt_rule_supplement_heading"` PromptRuleSupplementHeading string `yaml:"prompt_rule_supplement_heading"`
PromptRuleOutputRequirementsSection string `yaml:"prompt_rule_output_requirements_section"` PromptRuleOutputRequirementsSection string `yaml:"prompt_rule_output_requirements_section"`
ArticleImitationPromptTemplate string `yaml:"article_imitation_prompt_template"` ArticleImitationPromptTemplate string `yaml:"article_imitation_prompt_template"`
@@ -121,13 +121,6 @@ func PromptRuleWordCountSupplement(wordCount int) string {
return fmt.Sprintf(trimPrompt(loadRuntimePrompts().PromptRuleWordCountSupplementFormat), wordCount) return fmt.Sprintf(trimPrompt(loadRuntimePrompts().PromptRuleWordCountSupplementFormat), wordCount)
} }
func PromptRuleTargetPlatformSupplement(target string) string {
return fmt.Sprintf(
trimPrompt(loadRuntimePrompts().PromptRuleTargetPlatformSupplementFormat),
strings.ReplaceAll(target, ",", "、"),
)
}
func PromptRuleSupplementSection(items []string) string { func PromptRuleSupplementSection(items []string) string {
return trimPrompt(loadRuntimePrompts().PromptRuleSupplementHeading) + "\n- " + strings.Join(items, "\n- ") return trimPrompt(loadRuntimePrompts().PromptRuleSupplementHeading) + "\n- " + strings.Join(items, "\n- ")
} }
@@ -66,7 +66,21 @@ func (h *MonitoringHandler) CitationSummary(c *gin.Context) {
return return
} }
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days) brandID, err := parseOptionalInt64(c.Query("brand_id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40031, "invalid_brand_id", "brand_id must be a number"))
return
}
keywordID, err := parseOptionalInt64Pointer(c.Query("keyword_id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40031, "invalid_keyword_id", "keyword_id must be a number"))
return
}
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, c.Query("business_date"), aiPlatformID)
if svcErr != nil { if svcErr != nil {
response.Error(c, svcErr) response.Error(c, svcErr)
return return
+1 -1
View File
@@ -249,7 +249,7 @@ func RegisterRoutes(app *bootstrap.App) {
if swagger.EnabledForMode(app.Config.Server.Mode) { if swagger.EnabledForMode(app.Config.Server.Mode) {
swagger.Register(app.Engine, swagger.Config{ swagger.Register(app.Engine, swagger.Config{
Title: "Geo Rankly Admin API", Title: "省心推 Admin API",
Version: "dev", Version: "dev",
}) })
} }
@@ -0,0 +1 @@
-- Legacy article target platform JSON keys are intentionally not restored.
@@ -0,0 +1,11 @@
UPDATE articles
SET wizard_state_json = wizard_state_json - 'platforms' - 'target_platforms' - 'target_platform',
updated_at = NOW()
WHERE wizard_state_json IS NOT NULL
AND wizard_state_json ?| ARRAY['platforms', 'target_platforms', 'target_platform'];
UPDATE generation_tasks
SET input_params_json = input_params_json - 'platforms' - 'target_platforms' - 'target_platform',
updated_at = NOW()
WHERE input_params_json IS NOT NULL
AND input_params_json ?| ARRAY['platforms', 'target_platforms', 'target_platform'];