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"
content="width=device-width, initial-scale=1.0"
/>
<title>GEO 工具平台</title>
<title>省心推</title>
</head>
<body>
<div id="app"></div>
+1 -7
View File
@@ -1,18 +1,12 @@
<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 { computed } from "vue";
import { useI18n } from "vue-i18n";
import ImageUploadProgressOverlay from "@/components/ImageUploadProgressOverlay.vue";
const { locale } = useI18n();
const antLocale = computed(() => (locale.value === "en-US" ? enUS : zhCN));
</script>
<template>
<a-config-provider
:locale="antLocale"
:locale="zhCN"
:theme="{
token: {
colorPrimary: '#1677ff',
@@ -116,7 +116,7 @@ const deleteMutation = useMutation({
const columns = computed<TableColumnsType<ArticleListItem>>(() => [
{ 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.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.publishStatus"), dataIndex: "publish_status", key: "publish_status", width: 128 },
{ 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'">
{{ record.prompt_rule_name || "--" }}
</template>
<template v-else-if="column.key === 'platforms'">
{{ formatPublishPlatformList(record.platforms) }}
<template v-else-if="column.key === 'auto_publish_platforms'">
{{ formatPublishPlatformList(record.auto_publish_platforms) }}
</template>
<template v-else-if="column.key === 'generate_status'">
<ArticleGenerateStatus :status="record.generate_status" />
@@ -1,5 +1,5 @@
<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 { message } from "ant-design-vue";
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") }}
</p>
<div v-if="effectiveScheduleCoverEnabled" class="generate-task-drawer__cover-row">
<button type="button" class="generate-task-drawer__cover" @click="coverPickerOpen = true">
<template v-if="form.coverAssetUrl">
<img :src="form.coverAssetUrl" alt="cover preview" />
</template>
<template v-else>
<span class="generate-task-drawer__cover-plus">+</span>
<strong>{{ t("custom.task.coverUpload") }}</strong>
<small>{{ t("custom.task.scheduleCoverUploadHint") }}</small>
</template>
</button>
<div v-if="effectiveScheduleCoverEnabled" class="generate-task-drawer__cover-body">
<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">
<img :src="form.coverAssetUrl" alt="cover preview" />
</template>
<template v-else>
<span class="generate-task-drawer__cover-plus">+</span>
<span>{{ t("custom.task.coverUpload") }}</span>
</template>
</button>
<div v-if="form.coverAssetUrl" class="generate-task-drawer__cover-meta">
<strong>{{ form.coverFileName || t("custom.task.cover") }}</strong>
<a-button size="small" @click="coverPickerOpen = true">
{{ t("article.editor.coverReplace") }}
</a-button>
<a-button size="small" danger @click="handleRemoveScheduleCover">
{{ t("article.editor.coverRemove") }}
</a-button>
<button
v-if="form.coverAssetUrl"
type="button"
class="generate-task-drawer__cover-remove"
:aria-label="t('article.editor.coverRemove')"
:title="t('article.editor.coverRemove')"
@click.stop="handleRemoveScheduleCover"
>
<MinusCircleFilled />
</button>
</div>
</div>
</div>
@@ -806,8 +808,7 @@ function padTime(value: string): string {
gap: 4px;
}
.generate-task-drawer__account-copy strong,
.generate-task-drawer__cover-meta strong {
.generate-task-drawer__account-copy strong {
overflow: hidden;
color: #1f2937;
font-size: 14px;
@@ -846,7 +847,7 @@ function padTime(value: string): string {
.generate-task-drawer__cover-head {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 12px;
}
@@ -875,71 +876,72 @@ function padTime(value: string): string {
line-height: 1.7;
}
.generate-task-drawer__cover-row {
.generate-task-drawer__cover-body {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-top: 12px;
}
.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;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 228px;
min-height: 172px;
width: 100%;
min-height: 124px;
padding: 0;
border: 1px dashed #cfd9e8;
border: 1px dashed #d1d5db;
border-radius: 8px;
background: #fbfcff;
color: #1f2937;
background: #f9fafb;
color: #6b7280;
font-size: 13px;
cursor: pointer;
overflow: hidden;
transition: all 0.2s ease;
}
.generate-task-drawer__cover:hover {
border-color: #9db7ff;
background: #f7faff;
.generate-task-drawer__cover-preview:hover {
border-color: #6366f1;
background: #f5f3ff;
color: #6366f1;
}
.generate-task-drawer__cover img {
.generate-task-drawer__cover-preview img {
display: block;
width: 100%;
height: 172px;
height: 124px;
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 {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 999px;
background: #eef4ff;
color: #355dff;
font-size: 28px;
font-size: 20px;
line-height: 1;
}
.generate-task-drawer__cover-meta {
display: flex;
min-width: 220px;
max-width: 360px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
.generate-task-drawer__cover-remove {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
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 {
@@ -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.instant.executionStatus"), dataIndex: "status", key: "status", width: 140 },
{ 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.instant.createdTime"), dataIndex: "created_at", key: "created_at", width: 170 },
{ 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'">
{{ formatDateTime(record.execution_time) }}
</template>
<template v-else-if="column.key === 'platforms'">
{{ formatPublishPlatformList(record.platforms) }}
<template v-else-if="column.key === 'auto_publish_platforms'">
{{ formatPublishPlatformList(record.auto_publish_platforms) }}
</template>
<template v-else-if="column.key === 'generate_count'">
{{ record.generate_count || 1 }}
@@ -21,9 +21,6 @@ import {
publishStateLabel,
type PublishAccountCard,
} from "@/lib/publish-account-cards";
import {
normalizePublishPlatformIds,
} from "@/lib/publish-platforms";
const props = defineProps<{
open: boolean;
@@ -93,10 +90,8 @@ watch(
const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? []));
const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuery.data.value?.platforms ?? []));
const accountCards = computed<PublishAccountCard[]>(() =>
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value, articlePlatformIds.value),
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value),
);
watch(
@@ -137,10 +132,7 @@ watchEffect(() => {
return;
}
const preferredPlatforms = new Set(articlePlatformIds.value);
selectedAccountIds.value = accountCards.value
.filter((card) => card.selectable && preferredPlatforms.has(card.platformId))
.map((card) => card.id);
selectedAccountIds.value = [];
selectionHydrated.value = true;
});
@@ -237,7 +229,6 @@ async function persistCoverIfNeeded(detail: ArticleDetail): Promise<ArticleDetai
return articlesApi.update(detail.id, {
title: detail.title?.trim() || t("article.untitled"),
markdown_content: detail.markdown_content ?? "",
platforms: normalizePublishPlatformIds(detail.platforms ?? []),
cover_asset_url: nextUrl || null,
cover_image_asset_id: coverImageAssetId.value,
});
@@ -16,6 +16,7 @@ import ScheduleTaskModal from "@/components/ScheduleTaskModal.vue";
import { promptRulesApi, schedulesApi } from "@/lib/api";
import { formatCronExecutionTime, formatDateTime } from "@/lib/display";
import { formatError } from "@/lib/errors";
import { formatPublishPlatformList } from "@/lib/publish-platforms";
const queryClient = useQueryClient();
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.instant.executionStatus"), dataIndex: "status", key: "status", width: 140 },
{ 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.instant.createdTime"), dataIndex: "created_at", key: "created_at", width: 170 },
{ 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'">
{{ formatCronExecutionTime(record.cron_expr) }}
</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'">
{{
record.auto_publish
? t("custom.schedule.autoPublishAccountCount", { count: record.publish_account_ids?.length ?? 0 })
? formatPublishPlatformList(record.auto_publish_platforms)
: t("custom.schedule.manualPublish")
}}
</a-tag>
+3 -33
View File
@@ -1,44 +1,14 @@
import { createI18n } from "vue-i18n";
import enUS from "./messages/en-US";
import zhCN from "./messages/zh-CN";
export const LOCALE_STORAGE_KEY = "geo.admin.locale";
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";
}
const APP_LOCALE = "zh-CN";
export const i18n = createI18n({
legacy: false,
locale: resolveLocale(),
fallbackLocale: "en-US",
locale: APP_LOCALE,
fallbackLocale: APP_LOCALE,
messages: {
"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",
citationRanking: "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",
citationWindow30: "Last 30 days",
citationWindowLabel: "Last {days} days",
@@ -1290,9 +1290,9 @@ const enUS = {
name: "Task name",
rule: "Prompt rule",
cron: "Frequency",
autoPublishPlatforms: "Auto publish platforms",
autoPublish: "Auto publish",
manualPublish: "Manual publish",
autoPublishAccountCount: "Auto · {count} accounts",
nextRun: "Next run",
startAt: "Start time",
endAt: "End time",
+5 -5
View File
@@ -1,7 +1,7 @@
const zhCN = {
app: {
name: "GEO工具平台",
loginIntro: "面向企业的品牌资产管理与内容生产协作系统",
name: "省心推",
loginIntro: "面向企业和个人的品牌资产管理与内容生产协作系统",
},
locale: {
"zh-CN": "中文简体",
@@ -153,7 +153,7 @@ const zhCN = {
route: {
login: {
title: "欢迎回来",
description: "使用真实租户账号登录 GEO 工具平台。",
description: "登录省心推,开启品牌内容创作之旅",
},
workspace: {
title: "工作台",
@@ -433,7 +433,7 @@ const zhCN = {
backToQuestions: "返回问题列表",
citationRanking: "Citation Ranking",
citationRankingTitle: "引用排行",
citationRankingHint: "按主体域名归因 SaaS 发文在模型引用来源中的出现次数与占比。",
citationRankingHint: "按发布外链主体归因 SaaS 发文在模型引用来源中的出现次数与占比。",
citationWindow7: "近 7 天",
citationWindow30: "近 30 天",
citationWindowLabel: "近 {days} 天",
@@ -1300,9 +1300,9 @@ const zhCN = {
name: "任务名称",
rule: "关联规则",
cron: "执行频率",
autoPublishPlatforms: "自动发布平台",
autoPublish: "自动发布",
manualPublish: "手动发布",
autoPublishAccountCount: "自动 · {count} 个账号",
nextRun: "下次执行",
startAt: "开始时间",
endAt: "结束时间",
+4 -69
View File
@@ -16,7 +16,6 @@ import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { workspaceApi } from "@/lib/api";
import { type AppLocale, setAppLocale } from "@/i18n";
import { useAuthStore } from "@/stores/auth";
const route = useRoute();
@@ -86,22 +85,6 @@ type NavSection = {
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 base = [
{
@@ -220,10 +203,6 @@ function go(path: string): void {
void router.push(path);
}
function handleLocaleChange({ key }: { key: any }): void {
currentLocale.value = key as AppLocale;
}
async function handleLogout(): Promise<void> {
await authStore.logout();
await router.replace("/login");
@@ -298,22 +277,6 @@ async function handleLogout(): Promise<void> {
</nav>
<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 -->
<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>
@@ -390,6 +353,7 @@ async function handleLogout(): Promise<void> {
.admin-main-layout {
margin-left: 250px;
isolation: isolate;
}
.admin-main-layout--blocked {
@@ -424,7 +388,7 @@ async function handleLogout(): Promise<void> {
display: flex;
align-items: center;
justify-content: space-between;
z-index: 1;
z-index: 100;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
@@ -487,37 +451,6 @@ async function handleLogout(): Promise<void> {
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 {
display: flex;
align-items: center;
@@ -620,6 +553,8 @@ async function handleLogout(): Promise<void> {
}
.admin-content {
position: relative;
z-index: 0;
margin: 24px;
min-height: auto;
}
+4
View File
@@ -1078,6 +1078,10 @@ export const monitoringApi = {
},
citationSummary(params: {
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", {
params,
-1
View File
@@ -140,7 +140,6 @@ html, body, #app {
.ant-layout-header {
box-shadow: 0 1px 4px rgba(0,21,41,0.08); /* slight shadow for topbar */
z-index: 1;
}
.ant-card {
+1 -1
View File
@@ -161,7 +161,7 @@ const recentTemplateArticles = computed<ArticleListItem[]>(() =>
prompt_rule_id: null,
prompt_rule_name: null,
generation_mode: article.generation_mode,
platforms: [],
auto_publish_platforms: [],
generate_status: article.generate_status,
publish_status: article.publish_status,
title: article.title,
+163 -15
View File
@@ -13,7 +13,7 @@ import type {
MonitoringPlatformAuthorizationStatus,
} 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 { useRoute, useRouter } from "vue-router";
@@ -251,10 +251,19 @@ const citationSummaryQuery = useQuery({
queryKey: computed(() => [
"tracking",
"citation-summary",
selectedBrandId.value,
selectedKeywordId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
selectedCitationWindowDays.value,
]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () =>
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,
}),
});
@@ -495,25 +504,72 @@ const visibleCitedArticles = computed(() => {
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);
if (!segments.length) {
return {
background: "linear-gradient(180deg, #eef4ff 0%, #e5ecff 100%)",
};
return [];
}
let offset = 0;
const gradientStops = segments.map((item) => {
const next = offset + item.share * 360;
const stop = `${item.color} ${offset.toFixed(2)}deg ${next.toFixed(2)}deg`;
offset = next;
return stop;
});
const cx = 100;
const cy = 100;
const r = 100;
let offset = 0;
return segments.map((item) => {
const share = item.share;
if (share === 1) {
return {
...item,
isFull: true,
path: "",
};
}
const startAngle = offset * 2 * Math.PI - Math.PI / 2;
const endAngle = (offset + share) * 2 * Math.PI - Math.PI / 2;
const x1 = cx + r * Math.cos(startAngle);
const y1 = cy + r * Math.sin(startAngle);
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 {
...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: `conic-gradient(${gradientStops.join(", ")})`,
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-visual">
<div class="citation-ranking-visual__canvas">
<div class="citation-ranking-visual__circle" :style="citationChartStyle" />
<div class="citation-ranking-visual__canvas" @mouseleave="hoveredSlice = null" @mousemove="onPieMouseMove">
<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 v-if="citationRankingRows.length" class="citation-ranking-legend">
@@ -1390,12 +1476,74 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
}
.citation-ranking-visual__canvas {
position: relative;
display: flex;
align-items: center;
justify-content: center;
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 {
width: min(32vw, 420px);
height: min(32vw, 420px);
+2 -2
View File
@@ -1,6 +1,6 @@
appId: com.geo.rankly.desktop
productName: GEO Rankly Desktop
copyright: Copyright © 2026 GEO Rankly
productName: 省心推
copyright: Copyright © 2026 shengxintui.com. All Rights Reserved.
directories:
output: release
buildResources: build
+4 -3
View File
@@ -2,10 +2,11 @@
"name": "@geo/desktop-client",
"version": "0.1.0",
"private": true,
"description": "GEO Rankly Desktop — account health, monitor scheduler, and publish dispatch client.",
"description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。",
"author": {
"name": "GEO Rankly",
"email": "support@geo-rankly.local"
"name": "Liang Xu",
"email": "liangxu@qq.com",
"url": "https://shengxintui.com"
},
"type": "module",
"main": "out/main/bootstrap.cjs",
+2 -2
View File
@@ -340,7 +340,7 @@ async function createAppWindow(mode: WindowMode): Promise<ElectronBrowserWindow>
minWidth: initial.minWidth,
minHeight: initial.minHeight,
show: false,
title: "GEO Rankly Desktop",
title: "省心推",
icon: createDesktopHealthIcon("normal"),
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 14 },
@@ -414,7 +414,7 @@ async function createSettingsWindow(): Promise<ElectronBrowserWindow> {
minWidth: initial.minWidth,
minHeight: initial.minHeight,
show: false,
title: "GEO Rankly Desktop 设置",
title: "省心推设置",
icon: createDesktopHealthIcon("normal"),
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 14, y: 15 },
@@ -25,7 +25,7 @@ describe("playwright cdp target pruning", () => {
expect(isReclaimableHiddenPlaywrightTarget({
id: "renderer",
type: "page",
title: "GEO Rankly Desktop",
title: "省心推 - 账号健康监控与内容发布平台",
url: "http://localhost:5173/",
})).toBe(false);
});
@@ -165,7 +165,7 @@ function createLiveRuntimeSnapshot(controller: RuntimeControllerSnapshot) {
return {
generatedAt: now,
app: {
name: "GEO Rankly Desktop",
name: "省心推",
version: app.getVersion(),
channel: process.env.NODE_ENV === "development" ? "dev" : "release",
platform: process.platform,
+1 -1
View File
@@ -57,7 +57,7 @@ export function updateTrayIssueIndicator(issueCount: number): void {
tray.setImage(createTrayIcon(hasIssues ? "danger" : "normal"));
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 {
@@ -88,7 +88,7 @@ function openSettingsWindow() {
</div>
<div>
<p class="eyebrow">Workspace Cockpit</p>
<h1>GEO Rankly Desktop</h1>
<h1>省心推</h1>
</div>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
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"
/>
<title>GEO Rankly Desktop</title>
<title>省心推</title>
</head>
<body>
<div id="app"></div>
@@ -105,7 +105,7 @@ onBeforeUnmount(() => {
<div class="brand-logo">
<span class="brand-core"></span>
</div>
<h1 class="brand-name">GEO Rankly</h1>
<h1 class="brand-name">省心推</h1>
</header>
<form class="login-form" @submit.prevent="submitLogin">
-1
View File
@@ -210,7 +210,6 @@ runtime:
prompt_rule_scene_supplement_format: "适用场景:%s"
prompt_rule_tone_supplement_format: "默认语气:%s"
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
prompt_rule_supplement_heading: "补充要求:"
prompt_rule_output_requirements_section: |
输出要求:
-1
View File
@@ -210,7 +210,6 @@ runtime:
prompt_rule_scene_supplement_format: "适用场景:%s"
prompt_rule_tone_supplement_format: "默认语气:%s"
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
prompt_rule_supplement_heading: "补充要求:"
prompt_rule_output_requirements_section: |
输出要求:
+4 -4
View File
@@ -557,7 +557,7 @@ export interface ArticleListItem {
prompt_rule_id: number | null;
prompt_rule_name: string | null;
generation_mode: string | null;
platforms: string[];
auto_publish_platforms: string[];
generate_status: string;
publish_status: string;
title: string | null;
@@ -582,7 +582,7 @@ export interface ArticleDetail {
template_id: number | null;
template_name: string | null;
generation_mode: string | null;
platforms: string[];
auto_publish_platforms: string[];
cover_asset_url: string | null;
cover_image_asset_id: number | null;
generate_status: string;
@@ -632,7 +632,6 @@ export interface GenerateImitationResponse {
export interface UpdateArticleRequest {
title: string;
markdown_content: string;
platforms?: string[];
cover_asset_url?: string | null;
referenced_image_asset_ids?: number[];
cover_image_asset_id?: number | null;
@@ -1448,6 +1447,7 @@ export interface ScheduleTask {
cron_expr: string;
auto_publish: boolean;
publish_account_ids: string[];
auto_publish_platforms: string[];
cover_asset_url: string | null;
cover_image_asset_id: number | null;
enable_web_search: boolean;
@@ -1534,7 +1534,7 @@ export interface InstantTaskItem {
name: string;
status: string;
execution_time: string | null;
platforms: string[];
auto_publish_platforms: string[];
generate_count: number;
created_at: string;
}
-1
View File
@@ -210,7 +210,6 @@ runtime:
prompt_rule_scene_supplement_format: "适用场景:%s"
prompt_rule_tone_supplement_format: "默认语气:%s"
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
prompt_rule_supplement_heading: "补充要求:"
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 {
if strings.TrimSpace(cfg.Title) == "" {
cfg.Title = "Geo Rankly Tenant API"
cfg.Title = "省心推 客户 API"
}
if strings.TrimSpace(cfg.Version) == "" {
cfg.Version = "dev"
@@ -557,7 +557,7 @@ const swaggerIndexHTML = `<!doctype html>
<head>
<meta charset="utf-8" />
<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" />
<style>
body {
+12 -6
View File
@@ -115,7 +115,7 @@ type ArticleListItem struct {
PromptRuleID *int64 `json:"prompt_rule_id"`
PromptRuleName *string `json:"prompt_rule_name"`
GenerationMode *string `json:"generation_mode"`
Platforms []string `json:"platforms"`
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
GenerateStatus string `json:"generate_status"`
PublishStatus string `json:"publish_status"`
Title *string `json:"title"`
@@ -147,7 +147,7 @@ type ArticleDetailResponse struct {
TemplateID *int64 `json:"template_id"`
TemplateName *string `json:"template_name"`
GenerationMode *string `json:"generation_mode"`
Platforms []string `json:"platforms"`
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
GenerateStatus string `json:"generate_status"`
@@ -355,7 +355,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
}
item.SourceType = dbSourceType
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)
items = append(items, item)
}
@@ -407,7 +411,11 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
_ = json.Unmarshal(wizardStateJSON, &item.WizardState)
}
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.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
@@ -495,7 +503,6 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (
type UpdateArticleRequest struct {
Title string `json:"title"`
MarkdownContent string `json:"markdown_content"`
Platforms []string `json:"platforms"`
CoverAssetURL *string `json:"cover_asset_url"`
ReferencedImageAssetIDs []int64 `json:"referenced_image_asset_ids"`
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(
wizardStateJSON,
title,
req.Platforms,
req.CoverAssetURL,
resolvedCoverImageAssetInput,
)
@@ -31,16 +31,16 @@ type InstantTaskListParams struct {
}
type InstantTaskResponse struct {
ID int64 `json:"id"`
ArticleID *int64 `json:"article_id"`
PromptRuleID *int64 `json:"prompt_rule_id"`
PromptRuleName *string `json:"prompt_rule_name"`
Name string `json:"name"`
Status string `json:"status"`
ExecutionTime *string `json:"execution_time"`
Platforms []string `json:"platforms"`
GenerateCount int `json:"generate_count"`
CreatedAt string `json:"created_at"`
ID int64 `json:"id"`
ArticleID *int64 `json:"article_id"`
PromptRuleID *int64 `json:"prompt_rule_id"`
PromptRuleName *string `json:"prompt_rule_name"`
Name string `json:"name"`
Status string `json:"status"`
ExecutionTime *string `json:"execution_time"`
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
GenerateCount int `json:"generate_count"`
CreatedAt string `json:"created_at"`
}
type InstantTaskListResponse struct {
@@ -123,7 +123,11 @@ func (s *InstantTaskService) List(ctx context.Context, params InstantTaskListPar
item.ExecutionTime = stringPtrFromDBValue(executionAt)
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
if len(inputParamsJSON) > 0 {
@@ -284,6 +284,7 @@ type monitoringAliasResolution struct {
type monitoringAliasLookupInput struct {
NormalizedURL string
MatchKeys []string
RegistrableDomain string
SiteKey string
Host string
@@ -293,6 +294,7 @@ type monitoringAliasLookupInput struct {
type monitoringAliasCandidate struct {
NormalizedURL string
MatchKeys []string
ArticleID *int64
PublishRecordID *int64
RegistrableDomain string
@@ -2444,6 +2446,7 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
}
lookupInputs = append(lookupInputs, monitoringAliasLookupInput{
NormalizedURL: key,
MatchKeys: monitoringCitationCandidateKeys(item.URL, key),
RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)),
SiteKey: strings.ToLower(strings.TrimSpace(siteKey)),
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.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment))
candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle))
candidate.MatchKeys = monitoringCitationCandidateKeys(candidate.NormalizedURL, candidate.NormalizedURL)
candidates = append(candidates, candidate)
}
if err := rows.Err(); err != nil {
@@ -2567,11 +2571,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
bestScore := -1
bestAmbiguous := false
var best monitoringAliasCandidate
domainMatchedArticleIDs := map[int64]struct{}{}
for _, candidate := range candidates {
if monitoringAliasDomainMatches(input, candidate) && candidate.ArticleID != nil {
domainMatchedArticleIDs[*candidate.ArticleID] = struct{}{}
}
score := scoreMonitoringAliasCandidate(input, candidate)
if score < 0 {
continue
@@ -2586,27 +2586,16 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
bestAmbiguous = true
}
}
if bestScore == 40 && len(domainMatchedArticleIDs) == 1 {
bestScore = 60
}
if bestScore < 60 || bestAmbiguous {
if bestScore < 100 || bestAmbiguous {
continue
}
confidence := "medium"
status := "domain_matched"
if bestScore >= 100 {
confidence = "high"
status = "resolved"
} else if bestScore < 80 {
confidence = "low"
}
result[input.NormalizedURL] = monitoringAliasResolution{
ArticleID: cloneInt64(best.ArticleID),
PublishRecordID: cloneInt64(best.PublishRecordID),
NormalizedURLKey: best.NormalizedURL,
ResolutionStatus: status,
ResolutionConfidence: confidence,
ResolutionStatus: "resolved",
ResolutionConfidence: "high",
}
}
@@ -2618,23 +2607,19 @@ func scoreMonitoringAliasCandidate(input monitoringAliasLookupInput, candidate m
return 100
}
if !monitoringAliasDomainMatches(input, candidate) {
return -1
}
score := 40
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
for _, inputKey := range input.MatchKeys {
inputKey = strings.TrimSpace(inputKey)
if inputKey == "" {
continue
}
for _, candidateKey := range candidate.MatchKeys {
if inputKey == strings.TrimSpace(candidateKey) && candidateKey != "" {
return 100
}
}
}
return score
return -1
}
func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool {
@@ -425,23 +425,33 @@ func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) {
}
}
func TestScoreMonitoringAliasCandidateUsesDomainWithTitleEvidence(t *testing.T) {
lastPath := "article-42"
func TestScoreMonitoringAliasCandidateUsesCanonicalArticleKey(t *testing.T) {
input := monitoringAliasLookupInput{
NormalizedURL: "https://m.example.com/article-42",
RegistrableDomain: "example.com",
LastPathSegment: &lastPath,
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
NormalizedURL: "https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html",
MatchKeys: monitoringCitationCandidateKeys("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
}
candidate := monitoringAliasCandidate{
NormalizedURL: "https://www.example.com/posts/article-42",
RegistrableDomain: "example.com",
LastPathSegment: "article-42",
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
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 < 90 {
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want strong domain/title match", score)
if score := scoreMonitoringAliasCandidate(input, candidate); score != 100 {
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")
}
+261 -259
View File
@@ -414,29 +414,55 @@ func (s *MonitoringService) DashboardComposite(
func (s *MonitoringService) CitationSummary(
ctx context.Context,
days int,
brandID int64,
keywordID *int64,
businessDate string,
aiPlatformID *string,
) (*MonitoringCitationSummaryResponse, error) {
actor := auth.MustActor(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)
if err != nil {
return nil, err
}
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)
if err != nil {
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 {
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 {
return nil, err
}
@@ -2481,98 +2507,10 @@ func (s *MonitoringService) loadCitationRanking(
}
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
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
r.ai_platform_id,
COUNT(*) AS sample_count
FROM question_monitor_runs r
WHERE r.tenant_id = $1
AND ($2::bigint = 0 OR r.brand_id = $2)
AND r.collector_type = $3
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
GROUP BY r.ai_platform_id
),
citation_sources AS (
SELECT
r.ai_platform_id,
cf.id,
cf.run_id,
cf.article_id,
(
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
JOIN monitoring_citation_facts cf
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
WHERE r.tenant_id = $1
AND ($2::bigint = 0 OR r.brand_id = $2)
AND r.collector_type = $3
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
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))
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
return nil, err
}
defer rows.Close()
type citationRankingAggregate struct {
CitedAnswerCount int64
@@ -2583,14 +2521,29 @@ func (s *MonitoringService) loadCitationRanking(
}
aggregates := make(map[string]citationRankingAggregate)
rows, err := s.monitoringPool.Query(ctx, `
SELECT
r.ai_platform_id,
COUNT(*) AS sample_count
FROM question_monitor_runs r
WHERE r.tenant_id = $1
AND ($2::bigint = 0 OR r.brand_id = $2)
AND r.collector_type = $3
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
GROUP BY r.ai_platform_id
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
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
var citedAnswerCount int64
var citedArticleCount int64
var citationSourceCount int64
var saasSourceCount int64
if scanErr := rows.Scan(&platformID, &sampleCount, &citedAnswerCount, &citedArticleCount, &citationSourceCount, &saasSourceCount, new(sql.NullFloat64), new(sql.NullFloat64)); scanErr != nil {
if scanErr := rows.Scan(&platformID, &sampleCount); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
}
platformID = normalizeMonitoringPlatformID(platformID)
@@ -2598,16 +2551,80 @@ func (s *MonitoringService) loadCitationRanking(
continue
}
item := aggregates[platformID]
item.CitedAnswerCount += citedAnswerCount
item.CitedArticleCount += citedArticleCount
item.CitationSourceCount += citationSourceCount
item.SaaSSourceCount += saasSourceCount
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
r.ai_platform_id,
cf.run_id,
cf.cited_url,
cf.normalized_url
FROM question_monitor_runs r
JOIN monitoring_citation_facts cf
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
WHERE r.tenant_id = $1
AND ($2::bigint = 0 OR r.brand_id = $2)
AND r.collector_type = $3
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
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 runID int64
var citedURL string
var normalizedURL string
if scanErr := rows.Scan(&platformID, &runID, &citedURL, &normalizedURL); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
}
platformID = normalizeMonitoringPlatformID(platformID)
if platformID == "" {
continue
}
item := aggregates[platformID]
item.CitationSourceCount++
if platformID != "qwen" {
if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
item.SaaSSourceCount++
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
}
items := make([]MonitoringCitationRanking, 0, len(aggregates))
for platformID, aggregate := range aggregates {
if aggregate.SaaSSourceCount == 0 {
continue
}
items = append(items, MonitoringCitationRanking{
AIPlatformID: platformID,
PlatformName: platformDisplayName(platformID),
@@ -2648,6 +2665,11 @@ func (s *MonitoringService) loadCitedArticles(
}
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
var totalSampleCount int64
if err := s.monitoringPool.QueryRow(ctx, `
SELECT COUNT(*)
@@ -2664,78 +2686,78 @@ func (s *MonitoringService) loadCitedArticles(
}
rows, err := s.monitoringPool.Query(ctx, `
WITH article_counts AS (
SELECT
cf.article_id,
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
COUNT(*) AS citation_count
FROM monitoring_citation_facts cf
JOIN question_monitor_runs r
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
AND ($2::bigint = 0 OR cf.brand_id = $2)
AND cf.article_id IS NOT NULL
AND r.ai_platform_id <> 'qwen'
AND r.collector_type = $3
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
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
cf.cited_url,
cf.normalized_url
FROM monitoring_citation_facts cf
JOIN question_monitor_runs r
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
WHERE cf.tenant_id = $1
AND ($2::bigint = 0 OR cf.brand_id = $2)
AND r.ai_platform_id <> 'qwen'
AND r.collector_type = $3
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles")
}
defer rows.Close()
items := make([]MonitoringCitedArticle, 0)
type citedArticleAggregate struct {
ArticleID int64
ArticleTitle string
PublishPlatform string
CitationCount int64
}
aggregates := make(map[int64]citedArticleAggregate)
var totalArticleCitationCount int64
for rows.Next() {
var articleID int64
var title string
var publishPlatform string
var citationCount int64
var totalArticleCitationCount int64
if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount, &totalArticleCitationCount); scanErr != nil {
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")
}
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{
ArticleID: articleID,
ArticleTitle: title,
PublishPlatform: publishPlatform,
CitationCount: citationCount,
CitationRate: divideAsPointer(citationCount, totalSampleCount),
SourceShare: divideAsPointer(citationCount, totalArticleCitationCount),
ArticleID: item.ArticleID,
ArticleTitle: item.ArticleTitle,
PublishPlatform: item.PublishPlatform,
CitationCount: item.CitationCount,
CitationRate: divideAsPointer(item.CitationCount, totalSampleCount),
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
}
@@ -2923,6 +2945,11 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
return result, nil
}
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
rows, err := s.monitoringPool.Query(ctx, `
WITH filtered_citations AS (
SELECT
@@ -2930,10 +2957,10 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
cf.run_id,
cf.cited_url,
cf.cited_title,
cf.normalized_url,
cf.host,
cf.registrable_domain,
cf.site_key,
cf.article_id,
cf.resolution_status,
cf.resolution_confidence
FROM monitoring_citation_facts cf
@@ -2979,10 +3006,9 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
fc.run_id,
fc.cited_url,
fc.cited_title,
fc.normalized_url,
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,
fc.article_id,
alias.article_title_snapshot AS article_title,
fc.resolution_status,
fc.resolution_confidence
FROM filtered_citations fc
@@ -2990,8 +3016,6 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
ON dm.host = fc.host
AND dm.registrable_domain = fc.registrable_domain
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
`, tenantID, runIDs)
if err != nil {
@@ -3003,21 +3027,26 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
var runID int64
var citedURL string
var citedTitle sql.NullString
var normalizedURL string
var siteName string
var siteKey string
var articleID sql.NullInt64
var articleTitle sql.NullString
var resolutionStatus 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")
}
siteName = repairMonitoringMojibake(siteName)
if citedTitle.Valid {
citedTitle.String = repairMonitoringMojibake(citedTitle.String)
}
if articleTitle.Valid {
articleTitle.String = repairMonitoringMojibake(articleTitle.String)
var articleID *int64
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{
CitedURL: citedURL,
@@ -3025,12 +3054,15 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
SiteName: siteName,
SiteKey: siteKey,
FaviconURL: faviconURL(siteKey),
ArticleID: nullableInt64Value(articleID),
ArticleTitle: nullableStringValue(articleTitle),
ArticleID: articleID,
ArticleTitle: articleTitle,
ResolutionStatus: resolutionStatus,
ResolutionConfidence: resolutionConfidence,
})
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citations")
}
return result, nil
}
@@ -3040,15 +3072,21 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
return []MonitoringQuestionCitationStats{}, nil
}
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
rows, err := s.monitoringPool.Query(ctx, `
WITH filtered_facts AS (
SELECT
r.ai_platform_id,
cf.id,
cf.cited_url,
cf.normalized_url,
cf.host,
cf.registrable_domain,
cf.site_key,
cf.article_id
cf.site_key
FROM question_monitor_runs r
JOIN monitoring_citation_facts cf
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
LIMIT 1
) dm ON TRUE
),
grouped AS (
SELECT
ff.ai_platform_id,
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_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
LEFT JOIN domain_mappings dm
ON dm.host = ff.host
AND dm.registrable_domain = ff.registrable_domain
AND dm.site_key = ff.site_key
GROUP BY
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
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_key, ff.site_key) AS site_key,
COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain
FROM filtered_facts ff
LEFT JOIN domain_mappings dm
ON dm.host = ff.host
AND dm.registrable_domain = ff.registrable_domain
AND dm.site_key = ff.site_key
ORDER BY ff.ai_platform_id ASC, ff.id ASC
`, tenantID, runIDs)
if err != nil {
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)
for rows.Next() {
var platformID string
var citedURL string
var normalizedURL string
var siteName string
var siteKey string
var siteDomain string
var citationCount int64
var contentCitationCount int64
if scanErr := rows.Scan(&platformID, &siteName, &siteKey, &siteDomain, &citationCount, new(sql.NullFloat64), &contentCitationCount); scanErr != nil {
if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL, &siteName, &siteKey, &siteDomain); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis")
}
platformID = normalizeMonitoringPlatformID(platformID)
@@ -3174,10 +3183,17 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
SiteDomain: siteDomain,
}
}
item.CitationCount += citationCount
item.ContentCitationCount += contentCitationCount
item.CitationCount++
if platformID != "qwen" {
if _, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
item.ContentCitationCount++
}
}
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))
@@ -3206,40 +3222,23 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
return []MonitoringQuestionContentCitation{}, nil
}
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
rows, err := s.monitoringPool.Query(ctx, `
WITH platform_totals AS (
SELECT
r.ai_platform_id,
COUNT(cf.id) 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
WHERE r.tenant_id = $1
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
cf.cited_url,
cf.normalized_url
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.id = ANY($2)
GROUP BY cf.article_id, r.ai_platform_id
ORDER BY citation_count DESC, cf.article_id ASC
`, tenantID, runIDs)
if err != nil {
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)
totals := make(map[string]int64)
for rows.Next() {
var articleID int64
var articleTitle string
var publishPlatform string
var platformID string
var citationCount int64
var totalCitationCount int64
if scanErr := rows.Scan(&articleID, &articleTitle, &publishPlatform, &platformID, &citationCount, &totalCitationCount); scanErr != nil {
var citedURL string
var normalizedURL string
if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations")
}
platformID = normalizeMonitoringPlatformID(platformID)
if platformID == "" {
continue
}
totals[platformID]++
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
if !ok {
continue
}
key := contentCitationKey{
PlatformID: platformID,
ArticleID: articleID,
ArticleID: alias.ArticleID,
}
item := aggregates[key]
if item.ArticleID == 0 {
item = MonitoringQuestionContentCitation{
ArticleID: articleID,
ArticleTitle: articleTitle,
PublishPlatform: publishPlatform,
ArticleID: alias.ArticleID,
ArticleTitle: alias.ArticleTitle,
PublishPlatform: alias.PublishPlatform,
AIPlatformID: platformID,
PlatformName: platformDisplayName(platformID),
}
}
item.CitationCount += citationCount
item.CitationCount++
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))
@@ -343,7 +343,7 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
coverImageAssetID.Set = true
coverImageAssetID.Value = &rawCoverImageAssetID
}
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, nil, coverAssetURL, coverImageAssetID)
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, coverAssetURL, coverImageAssetID)
tx, err := s.pool.Begin(ctx)
if err != nil {
+71 -71
View File
@@ -1,9 +1,12 @@
package app
import (
"context"
"encoding/json"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
)
func normalizePlatformIDs(platformIDs []string) []string {
@@ -31,52 +34,79 @@ func normalizePlatformIDs(platformIDs []string) []string {
return normalized
}
func parsePlatformIDs(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
return normalizePlatformIDs(strings.Split(value, ","))
type scheduleAutoPublishPlatformQuerier interface {
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
}
func serializePlatformIDs(platformIDs []string) string {
normalized := normalizePlatformIDs(platformIDs)
if len(normalized) == 0 {
return ""
}
return strings.Join(normalized, ",")
}
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
func loadScheduleAutoPublishPlatforms(
ctx context.Context,
db scheduleAutoPublishPlatformQuerier,
tenantID int64,
inputParamsJSON []byte,
) ([]string, error) {
if db == nil || tenantID <= 0 || len(inputParamsJSON) == 0 {
return []string{}, nil
}
var payload map[string]interface{}
if err := json.Unmarshal(raw, &payload); err != nil {
return nil
if err := json.Unmarshal(inputParamsJSON, &payload); err != nil {
return []string{}, nil
}
if !extractBool(payload, "schedule_auto_publish") {
return []string{}, nil
}
if platforms := normalizePlatformsFromValue(payload["platforms"]); len(platforms) > 0 {
return platforms
}
if platforms := normalizePlatformsFromValue(payload["target_platforms"]); len(platforms) > 0 {
return platforms
accountIDs := normalizeSchedulePublishAccountIDs(extractStringList(payload["schedule_publish_account_ids"], 64))
if len(accountIDs) == 0 {
return []string{}, nil
}
if targetPlatform, ok := payload["target_platform"].(string); ok {
return parsePlatformIDs(targetPlatform)
return loadPublishAccountPlatformsByIDs(ctx, db, tenantID, accountIDs)
}
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
}
return 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 {
@@ -146,51 +176,21 @@ func extractInt64FromJSONPayload(raw []byte, key string) (int64, bool) {
}
}
func normalizePlatformsFromValue(raw interface{}) []string {
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) {
func mergeArticleWizardState(raw []byte, title string, coverAssetURL *string, coverImageAssetID NullableInt64Input) ([]byte, error) {
state := make(map[string]interface{})
if len(raw) > 0 {
_ = json.Unmarshal(raw, &state)
}
delete(state, "platforms")
delete(state, "target_platforms")
delete(state, "target_platform")
trimmedTitle := strings.TrimSpace(title)
if 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 {
trimmedCoverURL := strings.TrimSpace(*coverAssetURL)
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) {
if got := normalizePublishStatus("partial_success"); got != "partial_success" {
t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got)
@@ -54,26 +54,27 @@ type ScheduleTaskRequest struct {
}
type ScheduleTaskResponse struct {
ID int64 `json:"id"`
WorkspaceID *int64 `json:"workspace_id"`
PromptRuleID int64 `json:"prompt_rule_id"`
PromptRuleName *string `json:"prompt_rule_name"`
BrandID *int64 `json:"brand_id"`
BrandName *string `json:"brand_name"`
Name string `json:"name"`
CronExpr string `json:"cron_expr"`
AutoPublish bool `json:"auto_publish"`
PublishAccountIDs []string `json:"publish_account_ids"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
EnableWebSearch bool `json:"enable_web_search"`
GenerateCount int `json:"generate_count"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
NextRunAt *string `json:"next_run_at"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID int64 `json:"id"`
WorkspaceID *int64 `json:"workspace_id"`
PromptRuleID int64 `json:"prompt_rule_id"`
PromptRuleName *string `json:"prompt_rule_name"`
BrandID *int64 `json:"brand_id"`
BrandName *string `json:"brand_name"`
Name string `json:"name"`
CronExpr string `json:"cron_expr"`
AutoPublish bool `json:"auto_publish"`
PublishAccountIDs []string `json:"publish_account_ids"`
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
EnableWebSearch bool `json:"enable_web_search"`
GenerateCount int `json:"generate_count"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
NextRunAt *string `json:"next_run_at"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ScheduleTaskListParams struct {
@@ -206,7 +207,7 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
return &ScheduleTaskResponse{
ID: id, WorkspaceID: &workspaceID, PromptRuleID: req.PromptRuleID, BrandID: req.BrandID,
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,
GenerateCount: generateCount,
Status: status, CreatedAt: fmt.Sprintf("%v", createdAt),
@@ -400,6 +401,9 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID in
if err != nil {
return nil, err
}
if err := s.populateScheduleAutoPublishPlatforms(ctx, tenantID, &item); err != nil {
return nil, err
}
items = append(items, item)
}
return &ScheduleTaskListResponse{Items: items, Total: total}, nil
@@ -425,9 +429,24 @@ func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenant
}
return nil, false, err
}
if err := s.populateScheduleAutoPublishPlatforms(ctx, tenantID, &item); err != nil {
return nil, false, err
}
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 {
Scan(dest ...interface{}) error
}) (ScheduleTaskResponse, error) {
@@ -480,6 +499,7 @@ func (s *ScheduleTaskService) ensurePromptRuleExists(ctx context.Context, tenant
type scheduleAutoPublishConfig struct {
AutoPublish bool
AccountIDs []string
PlatformIDs []string
AccountIDsJSON []byte
CoverAssetURL *string
CoverImageAssetID *int64
@@ -514,6 +534,7 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
if err != nil {
return config, err
}
config.PlatformIDs = normalizePlatformIDs(platformIDs)
if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" {
return config, response.ErrBadRequest(40025, "cover_required", "selected publish accounts require cover_asset_url")
}
+32 -33
View File
@@ -21,39 +21,38 @@ type promptsConfig struct {
}
type runtimePromptsConfig struct {
DefaultGenerationBasePromptTemplate string `yaml:"default_generation_base_prompt_template"`
PromptContextHeading string `yaml:"prompt_context_heading"`
GenerationWritingRequirementsSection string `yaml:"generation_writing_requirements_section"`
GenerationTemplateSpecificRulesHeading string `yaml:"generation_template_specific_rules_heading"`
GenerationLengthGuidanceHeading string `yaml:"generation_length_guidance_heading"`
TopXBrandPriorityRulesTemplate string `yaml:"top_x_brand_priority_rules_template"`
EnglishLengthGuidanceTemplate string `yaml:"english_length_guidance_template"`
ChineseLengthGuidanceTemplate string `yaml:"chinese_length_guidance_template"`
ContextLabels map[string]string `yaml:"context_labels"`
TemplateContextWithJSONExampleTemplate string `yaml:"template_context_with_json_example_template"`
JSONOutputExampleHeading string `yaml:"json_output_example_heading"`
AnalyzeOutputExample string `yaml:"analyze_output_example"`
AnalyzeFallbackPromptTemplate string `yaml:"analyze_fallback_prompt_template"`
TitleCustomOutputRequirementsSection string `yaml:"title_custom_output_requirements_section"`
TitleOutputExample string `yaml:"title_output_example"`
TitleFallbackPromptTemplate string `yaml:"title_fallback_prompt_template"`
OutlineOutputExample string `yaml:"outline_output_example"`
OutlineCustomOutputRequirementsSection string `yaml:"outline_custom_output_requirements_section"`
OutlineFallbackPromptTemplate string `yaml:"outline_fallback_prompt_template"`
OutlineResponseFormatDescription string `yaml:"outline_response_format_description"`
PromptRuleTaskNameSupplementFormat string `yaml:"prompt_rule_task_name_supplement_format"`
PromptRuleSceneSupplementFormat string `yaml:"prompt_rule_scene_supplement_format"`
PromptRuleToneSupplementFormat string `yaml:"prompt_rule_tone_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"`
PromptRuleOutputRequirementsSection string `yaml:"prompt_rule_output_requirements_section"`
ArticleImitationPromptTemplate string `yaml:"article_imitation_prompt_template"`
ArticleImitationLocaleInstructions map[string]string `yaml:"article_imitation_locale_instructions"`
ArticleImitationSettingLabels map[string]string `yaml:"article_imitation_setting_labels"`
KnowledgePromptIntroLines []string `yaml:"knowledge_prompt_intro_lines"`
KnowledgeSnippetLabelFormat string `yaml:"knowledge_snippet_label_format"`
KnowledgeWebsiteMarkdown knowledgeWebsiteMarkdownConfig `yaml:"knowledge_website_markdown"`
DefaultGenerationBasePromptTemplate string `yaml:"default_generation_base_prompt_template"`
PromptContextHeading string `yaml:"prompt_context_heading"`
GenerationWritingRequirementsSection string `yaml:"generation_writing_requirements_section"`
GenerationTemplateSpecificRulesHeading string `yaml:"generation_template_specific_rules_heading"`
GenerationLengthGuidanceHeading string `yaml:"generation_length_guidance_heading"`
TopXBrandPriorityRulesTemplate string `yaml:"top_x_brand_priority_rules_template"`
EnglishLengthGuidanceTemplate string `yaml:"english_length_guidance_template"`
ChineseLengthGuidanceTemplate string `yaml:"chinese_length_guidance_template"`
ContextLabels map[string]string `yaml:"context_labels"`
TemplateContextWithJSONExampleTemplate string `yaml:"template_context_with_json_example_template"`
JSONOutputExampleHeading string `yaml:"json_output_example_heading"`
AnalyzeOutputExample string `yaml:"analyze_output_example"`
AnalyzeFallbackPromptTemplate string `yaml:"analyze_fallback_prompt_template"`
TitleCustomOutputRequirementsSection string `yaml:"title_custom_output_requirements_section"`
TitleOutputExample string `yaml:"title_output_example"`
TitleFallbackPromptTemplate string `yaml:"title_fallback_prompt_template"`
OutlineOutputExample string `yaml:"outline_output_example"`
OutlineCustomOutputRequirementsSection string `yaml:"outline_custom_output_requirements_section"`
OutlineFallbackPromptTemplate string `yaml:"outline_fallback_prompt_template"`
OutlineResponseFormatDescription string `yaml:"outline_response_format_description"`
PromptRuleTaskNameSupplementFormat string `yaml:"prompt_rule_task_name_supplement_format"`
PromptRuleSceneSupplementFormat string `yaml:"prompt_rule_scene_supplement_format"`
PromptRuleToneSupplementFormat string `yaml:"prompt_rule_tone_supplement_format"`
PromptRuleWordCountSupplementFormat string `yaml:"prompt_rule_word_count_supplement_format"`
PromptRuleSupplementHeading string `yaml:"prompt_rule_supplement_heading"`
PromptRuleOutputRequirementsSection string `yaml:"prompt_rule_output_requirements_section"`
ArticleImitationPromptTemplate string `yaml:"article_imitation_prompt_template"`
ArticleImitationLocaleInstructions map[string]string `yaml:"article_imitation_locale_instructions"`
ArticleImitationSettingLabels map[string]string `yaml:"article_imitation_setting_labels"`
KnowledgePromptIntroLines []string `yaml:"knowledge_prompt_intro_lines"`
KnowledgeSnippetLabelFormat string `yaml:"knowledge_snippet_label_format"`
KnowledgeWebsiteMarkdown knowledgeWebsiteMarkdownConfig `yaml:"knowledge_website_markdown"`
}
type knowledgeWebsiteMarkdownConfig struct {
@@ -121,13 +121,6 @@ func PromptRuleWordCountSupplement(wordCount int) string {
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 {
return trimPrompt(loadRuntimePrompts().PromptRuleSupplementHeading) + "\n- " + strings.Join(items, "\n- ")
}
@@ -66,7 +66,21 @@ func (h *MonitoringHandler) CitationSummary(c *gin.Context) {
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 {
response.Error(c, svcErr)
return
+1 -1
View File
@@ -249,7 +249,7 @@ func RegisterRoutes(app *bootstrap.App) {
if swagger.EnabledForMode(app.Config.Server.Mode) {
swagger.Register(app.Engine, swagger.Config{
Title: "Geo Rankly Admin API",
Title: "省心推 Admin API",
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'];