Compare commits

...

6 Commits

Author SHA1 Message Date
root 0b5d8d72f2 fix(brand-questions): accept generic search terms in lexicon validation
Deployment Config CI / Deployment Config (push) Successful in 27s
Frontend CI / Frontend (push) Successful in 2m53s
Backend CI / Backend (push) Successful in 16m53s
Desktop Client Build / Resolve Build Metadata (push) Waiting to run
Desktop Client Build / Build Desktop Client (push) Blocked by required conditions
Desktop Client Build / Publish Client Artifacts to NAS (push) Has been cancelled
Search terms like "全屋定制" or "GEO排名优化" were rejected as invalid because
both the Vue candidate validator and the Go question_metadata classifier
required interrogative cues (什么/如何/为什么/...). Lexicon entries are search
terms, not questions, so require only letters or digits plus the existing
4-char minimum. Rename helpers to searchTermLooksValid/questionLooksValid
to reflect the looser semantics, and relabel the navigation entry from
"公司和词库" to "品牌和词库" to match the actual domain.
2026-05-24 22:19:57 +08:00
root 52997e36fe fix(content-gen): strip URLs and site-list sections from generated content
Forbid URLs/domains/markdown links across prompt rules (runtime, platform
templates, outline/imitation/title flows) and rename "官网" placeholder to
"官网信息" with explicit instructions that links are background only.
Add sanitizeGeneratedArticleMarkdown to strip URLs, HTML images, and
"网站列表/官网列表" labels from LLM output, wired into article, prompt-rule,
template, and outline generation. Drop the site_list outline section seed
and filter blocked outline keys in the wizard so stored drafts cannot
resurrect it.
2026-05-24 22:19:43 +08:00
root 946a084c77 style(admin-web): drop unused min-width on templates desktop table
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:24:36 +08:00
root a9591143e8 fix(toutiao): fail publish when body images fail to upload
Route content/cover images through the desktop media-image asset
pipeline and abort before submitting when any body image was not
replaced with a Toutiao-hosted URL, so we don't publish articles
that reference our own private asset endpoints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:24:28 +08:00
root 8761e47f78 feat(kol): cache marketplace and generation reads with singleflight
Add cache-aside + singleflight to KOL marketplace list/detail, my-subscription
prompts, and generation schema lookups. Invalidate the shared public-facing
caches when subscriptions are added/revoked (ops) or KOL profile is updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:33:17 +08:00
root 09c8fe1e56 fix desktop operator name fallback 2026-05-24 10:23:50 +08:00
32 changed files with 921 additions and 165 deletions
+3 -3
View File
@@ -79,7 +79,7 @@ const zhCN = {
media: "媒体管理",
publishManagement: "发文管理",
brandManagement: "品牌管理",
brands: "公司和词库",
brands: "品牌和词库",
tracking: "数据追踪",
trackingDetail: "数据详情",
contentManagement: "内容管理",
@@ -222,7 +222,7 @@ const zhCN = {
description: "查看桌面端发布任务、外链、账号工作台入口和重发状态。",
},
brands: {
title: "公司和词库",
title: "品牌和词库",
description: "围绕品牌维护关键词库(搜索词)和竞品信息。",
},
brandQuestionCreate: {
@@ -1275,7 +1275,7 @@ const zhCN = {
},
brands: {
eyebrow: "Company & Lexicon",
title: "公司和词库",
title: "品牌和词库",
description: "通过品牌名称和品牌相关信息维护关键词库(搜索词)和竞品资产。",
railTitle: "品牌库",
selectBrand: "选择品牌后即可继续维护关键词库(搜索词)。",
@@ -506,12 +506,12 @@ function normalizeSearchTermText(value: string): string {
.replace(/\s+/g, '')
}
function questionLooksLikeQuestion(value: string): boolean {
function searchTermLooksValid(value: string): boolean {
const text = normalizeSearchTermText(value)
if (text.length < 4) {
return false
}
return /什么|如何|怎么|哪|为什么|是否|能不能|适合|区别|对比|推荐/.test(text)
return /[\p{L}\p{N}]/u.test(text)
}
function toCandidateRows(candidates: QuestionCandidate[]): CandidateRow[] {
@@ -519,7 +519,7 @@ function toCandidateRows(candidates: QuestionCandidate[]): CandidateRow[] {
.map((candidate, index) => {
const text = normalizeSearchTermText(candidate.text)
const tooShort = text.length < 4
const invalid = !questionLooksLikeQuestion(text)
const invalid = !searchTermLooksValid(text)
return {
...candidate,
text,
@@ -545,7 +545,7 @@ function buildLocalCandidates(texts: string[], source: QuestionSource): Candidat
seen.add(key)
const tooShort = text.length < 4
const duplicate = existingQuestionSet.value.has(key) || duplicateInBatch
const invalid = !questionLooksLikeQuestion(text)
const invalid = !searchTermLooksValid(text)
rows.push({
id: `${Date.now()}-${rows.length}-${text}`,
text,
@@ -682,7 +682,7 @@ function refreshCandidate(row: CandidateRow): void {
)
row.too_short = row.text.length < 4
row.duplicate = Boolean(key) && (existingQuestionSet.value.has(key) || duplicateInBatch)
row.suggest_skip = row.too_short || row.duplicate || !questionLooksLikeQuestion(row.text)
row.suggest_skip = row.too_short || row.duplicate || !searchTermLooksValid(row.text)
}
function refreshAllCandidates(): void {
@@ -379,9 +379,10 @@ const baseOutlineOptions = computed<ResolvedOutlineOption[]>(() => {
)
return [...configured, ...customOutlineSections.value]
})
const outlineOptions = computed<ResolvedOutlineOption[]>(() =>
sortOutlineOptions(baseOutlineOptions.value, outlineOptionOrder.value),
)
const outlineOptions = computed<ResolvedOutlineOption[]>(() => {
const visible = baseOutlineOptions.value.filter((section) => !isBlockedOutlineOption(section))
return sortOutlineOptions(visible, outlineOptionOrder.value)
})
const selectedOutlineLabels = computed(() =>
outlineOptions.value
.filter((section) => outlineSections.value.includes(section.key))
@@ -517,7 +518,7 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
competitorCount: 0,
}),
)
.filter((section) => section.default)
.filter((section) => section.default && !isBlockedOutlineOption(section))
.map((section) => section.key)
outlineOptionOrder.value = buildOutlineOptions(
templateCardConfig.value.wizard?.outline,
@@ -532,7 +533,9 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
inputParams: {},
competitorCount: 0,
}),
).map((section) => section.key)
)
.filter((section) => !isBlockedOutlineOption(section))
.map((section) => section.key)
outlineSections.value = defaults
}
@@ -562,9 +565,9 @@ function applyDraftArticleState(detail: ArticleDetail): void {
selectedKnowledgeGroupIds.value = numberListFromUnknown(draftState.knowledge_group_ids) ?? []
customOutlineSections.value = parseCustomOutlineSections(draftState.custom_outline_sections)
outlineOptionOrder.value =
stringListFromUnknown(draftState.outline_option_order) ?? outlineOptionOrder.value
sanitizeOutlineKeys(stringListFromUnknown(draftState.outline_option_order)) ?? outlineOptionOrder.value
outlineSections.value =
stringListFromUnknown(draftState.outline_sections) ?? outlineSections.value
sanitizeOutlineKeys(stringListFromUnknown(draftState.outline_sections)) ?? outlineSections.value
generatedOutline.value = decorateOutlineNodes(parseOutlineNodes(draftState.generated_outline))
restoringDraft.value = false
@@ -1307,8 +1310,8 @@ function buildDraftState(): Record<string, JsonValue> {
title: finalTitle.value || selectedTitle.value || '',
review_intro_hook: reviewIntroHook.value,
knowledge_group_ids: selectedKnowledgeGroupIds.value,
outline_sections: outlineSections.value,
outline_option_order: outlineOptionOrder.value,
outline_sections: sanitizeOutlineKeys(outlineSections.value) ?? [],
outline_option_order: sanitizeOutlineKeys(outlineOptionOrder.value) ?? [],
custom_outline_sections: customOutlineSections.value.map((item) => ({
key: item.key,
label: item.label,
@@ -1764,6 +1767,23 @@ function buildOutlineOptions(
}))
}
function isBlockedOutlineOption(section: ResolvedOutlineOption): boolean {
const key = section.key.trim().toLowerCase()
const label = section.label.trim()
return key === 'site_list' || ['网站列表', '官网列表', '网址列表'].includes(label)
}
function sanitizeOutlineKeys(keys: string[] | undefined): string[] | undefined {
if (!keys) {
return undefined
}
const blockedKeys = new Set(
baseOutlineOptions.value.filter((section) => isBlockedOutlineOption(section)).map((section) => section.key),
)
const filtered = keys.filter((key) => !blockedKeys.has(key) && key.trim().toLowerCase() !== 'site_list')
return filtered.length > 0 ? filtered : []
}
function buildFallbackTitles(
_templateKey: string | undefined,
currentBrand: string,
@@ -911,10 +911,6 @@ function refreshRecords(): void {
border-radius: 12px;
}
.templates-view__desktop-table :deep(.ant-table) {
min-width: 1068px;
}
.templates-view__filters-inline {
display: flex;
align-items: center;
@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from 'vitest'
import {
publishToutiaoArticle,
type ToutiaoPublishTransport,
} from '../../../../../packages/publisher-platforms/src/toutiao'
function createTransport(
overrides: Partial<ToutiaoPublishTransport> = {},
): ToutiaoPublishTransport & { submitCalls: URLSearchParams[] } {
const submitCalls: URLSearchParams[] = []
const transport: ToutiaoPublishTransport & { submitCalls: URLSearchParams[] } = {
submitCalls,
async fetchJson(input, init) {
if (input.includes('/media/get_media_info')) {
return {
data: {
user: {
id_str: 'uid-1',
screen_name: '头条号测试账号',
},
media: {},
},
} as never
}
if (input.includes('/spice/image')) {
return {
data: {
image_url: 'https://p3-sign.toutiaoimg.com/tos-cn-i-body/image.png',
image_uri: 'tos-cn-i-body/image.png',
image_width: 640,
image_height: 360,
},
} as never
}
if (input.includes('/article/publish')) {
submitCalls.push(init?.body as URLSearchParams)
return {
code: 0,
data: {
pgc_id: 'pgc-1',
},
} as never
}
return {} as never
},
async fetchImageBlob() {
return new Blob(['image'], { type: 'image/png' })
},
async uploadHtmlImages(html, uploader) {
const source = '/api/public/assets/body-token'
const target = await uploader(source)
return {
html: target ? html.split(source).join(target) : html,
uploaded: target ? new Map([[source, target]]) : new Map(),
}
},
...overrides,
}
return transport
}
describe('publishToutiaoArticle', () => {
it('submits content with body images replaced by Toutiao image URLs', async () => {
const transport = createTransport()
const result = await publishToutiaoArticle(
{
title: '标题',
html: '<p><img src="/api/public/assets/body-token" alt="" /></p>',
publishType: 'publish',
},
transport,
)
expect(result.success).toBe(true)
expect(transport.submitCalls).toHaveLength(1)
expect(transport.submitCalls[0]?.get('content')).toContain('https://p3-sign.toutiaoimg.com')
expect(transport.submitCalls[0]?.get('content')).not.toContain('/api/public/assets/body-token')
})
it('fails before submit when any body image cannot be uploaded to Toutiao', async () => {
const transport = createTransport({
async uploadHtmlImages(html) {
return {
html,
uploaded: new Map(),
}
},
})
const fetchJson = vi.spyOn(transport, 'fetchJson')
const result = await publishToutiaoArticle(
{
title: '标题',
html: '<p><img src="/api/public/assets/body-token" alt="" /></p>',
publishType: 'publish',
},
transport,
)
expect(result).toMatchObject({
success: false,
status: 'failed',
code: 'toutiaohao_image_upload_failed',
})
expect(fetchJson.mock.calls.some(([input]) => input.includes('/article/publish'))).toBe(false)
})
})
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../../../../../packages/publisher-platforms/src/toutiao', () => ({
publishToutiaoArticle: vi.fn(async () => ({
success: true,
status: 'success',
articleId: 'pgc-1',
mediaName: '头条号测试账号',
externalManageUrl: 'https://mp.toutiao.com/profile_v4/index',
externalArticleUrl: 'https://www.toutiao.com/article/pgc-1/',
message: '头条号发布成功。',
})),
}))
vi.mock('./media-image', () => ({
fetchImageAssetBlob: vi.fn(async () => ({
blob: new Blob(['image'], { type: 'image/png' }),
width: 1,
height: 1,
fileName: 'image.png',
})),
}))
import { publishToutiaoArticle } from '../../../../../packages/publisher-platforms/src/toutiao'
import { toutiaoAdapter } from './toutiao'
import { fetchImageAssetBlob } from './media-image'
describe('toutiao adapter', () => {
it('fetches relative public assets through the desktop asset pipeline before platform upload', async () => {
const result = await toutiaoAdapter.publish(
{
taskId: 'task-1',
accountId: 'account-1',
session: {} as never,
view: null,
playwright: null,
signal: new AbortController().signal,
phase: 'initial',
reportProgress: vi.fn(),
article: {
article_id: 889,
title: '2026年室内门锁怎么选?五款高适配产品参考',
html_content: null,
markdown_content:
'<p class="article-editor-image article-editor-image--center" align="center"><img src="/api/public/assets/body-token" alt="" /></p>',
cover_asset_url: '/api/public/assets/cover-token',
},
},
{},
)
expect(result.status).toBe('succeeded')
expect(publishToutiaoArticle).toHaveBeenCalledOnce()
const transport = vi.mocked(publishToutiaoArticle).mock.calls[0]?.[1]
expect(transport).toBeTruthy()
const bodyBlob = await transport?.fetchImageBlob('/api/public/assets/body-token')
const coverBlob = await transport?.fetchImageBlob('/api/public/assets/cover-token')
expect(bodyBlob?.type).toBe('image/png')
expect(coverBlob?.type).toBe('image/png')
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/body-token')
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/cover-token')
})
})
@@ -2,7 +2,12 @@ import type { JsonValue } from '@geo/shared-types'
import { publishToutiaoArticle } from '../../../../../packages/publisher-platforms/src/toutiao'
import type { PublishAdapter } from './base'
import { fetchImageBlob, normalizeArticleHtml, sessionFetchJson, uploadHtmlImages } from './common'
import { normalizeArticleHtml, sessionFetchJson, uploadHtmlImages } from './common'
import { fetchImageAssetBlob } from './media-image'
async function fetchToutiaoImageBlob(sourceUrl: string): Promise<Blob | null> {
return (await fetchImageAssetBlob(sourceUrl))?.blob ?? null
}
function buildResultPayload(
articleId: string,
@@ -36,7 +41,7 @@ export const toutiaoAdapter: PublishAdapter = {
},
{
fetchJson: (input, init) => sessionFetchJson(context.session, input, init),
fetchImageBlob,
fetchImageBlob: fetchToutiaoImageBlob,
uploadHtmlImages,
reportProgress(stage) {
context.reportProgress(`toutiao.${stage}`)
@@ -64,8 +64,24 @@ const navItems = computed(() => {
]
})
const operatorName = computed(() => session.value?.user?.name ?? '未命名操作员')
const operatorEmail = computed(() => session.value?.user?.email ?? '')
const operatorName = computed(() => {
const user = session.value?.user
return (
user?.name?.trim() ||
user?.phone?.trim() ||
user?.email?.trim() ||
'未命名操作员'
)
})
const operatorEmail = computed(() => {
const user = session.value?.user
const email = user?.email?.trim()
const phone = user?.phone?.trim()
if (email && phone && email !== operatorName.value) {
return email
}
return phone && phone !== operatorName.value ? phone : ''
})
const liveClientLabel = computed(() => snapshot.value?.clients[0]?.deviceName ?? null)
const clientLabel = computed(
() =>
+17 -8
View File
@@ -18,7 +18,8 @@ runtime:
- 保持客观、克制、非广告化,不使用夸张宣传语、绝对化表述或暗示性背书,如“顶级”“第一”“首选”“最靠谱”“权威认证”“唯一”“零投诉”等。
- 如涉及品牌、公司、机构或服务商比较,默认按信息整理顺序展开,不构成排名、评奖或唯一推荐;不要刻意贬低同行。
- 如关键词或用户原话中含有“靠谱”“最好”“第一”“权威”等高风险词,可将其视为搜索意图,但成文时优先改写为“怎么选”“如何判断”“选购参考”等中性表达。
- 非必要时不要输出联系方式、购买引导、广告标识、治疗/收益保证或其他容易触发平台审核风险的表述;若用户明确要求,或知识库中已提供且确需引用门店地址、官网、邮箱、电话、营业时间、主营业务、经营范围、服务范围、核心产品等信息,只能严格依据已提供内容,不得改写成不存在的业务,不得混写、推测或补全
- 正文禁止出现任何网址、域名、网页链接或 Markdown 链接;如果上下文里提供了官网或来源链接,只能作为理解背景,不得原样写进文章
- 非必要时不要输出联系方式、购买引导、广告标识、治疗/收益保证或其他容易触发平台审核风险的表述;若用户明确要求,或知识库中已提供且确需引用门店地址、邮箱、电话、营业时间、主营业务、经营范围、服务范围、核心产品等信息,只能严格依据已提供内容,不得改写成不存在的业务,不得混写、推测或补全。
- 使用清晰的小节标题和短段落;仅在确实有助于理解时使用列表。
- 结尾应给出清晰结论、适合对象、不适合对象、信息边界或下一步建议,帮助读者完成判断。
generation_template_specific_rules_heading: "模板专项要求:"
@@ -206,7 +207,7 @@ runtime:
{
"outline": [
{
"outline": "网站列表",
"outline": "品牌信息整理",
"children": [
{ "outline": "竞品 A" },
{ "outline": "竞品 B" }
@@ -230,6 +231,7 @@ runtime:
- supplemental_questions 只安排为正文主体的自然覆盖点,不作为文章标题或大纲顶层主线。
- 结尾预留 1 个加粗点题句,用来回应 supplemental_questions 覆盖过的问题。
- 尊重当前优化关键词、竞品和关键要点,因为用户可能已手动编辑。
- 大纲禁止出现任何网址、域名、网页链接或“网站列表/官网列表/网址列表”这类段落。
- 若涉及多品牌或多对象比较,按信息整理顺序组织,不要把大纲写成排名、榜单或评奖结构。
- 在合适位置体现选择依据、适合场景、信息边界和总结建议。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
@@ -248,6 +250,7 @@ runtime:
- supplemental_questions 只安排为正文主体的自然覆盖点,不作为文章标题或大纲顶层主线。
- 结尾预留 1 个加粗点题句,用来回应 supplemental_questions 覆盖过的问题。
- 尊重当前标题、优化关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
- 大纲禁止出现任何网址、域名、网页链接或“网站列表/官网列表/网址列表”这类段落。
- 若涉及多品牌或多对象比较,按信息整理顺序展开,不构成排名或评奖。
- 大纲应具体、不含推广语气,适合后续完整成文。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
@@ -269,6 +272,7 @@ runtime:
2. 标题使用一级标题,并由 AI 根据正文内容自行生成,不要直接复用任务名称。
3. 不要输出你的思考过程、解释或致歉。
4. 保持客观中立,避免排名、绝对化承诺、权威背书、夸大宣传和刻意贬损同行。
5. 文章禁止出现任何网址、域名、网页链接或 Markdown 链接。
article_imitation_prompt_template: |
你是一名资深内容策略编辑,请基于给定源文章做高质量仿写创作。
@@ -279,6 +283,7 @@ runtime:
- 保留源文章中的事实信息、论证逻辑和可复用观点。
- 不要逐句同义替换,不要复制源文的独特措辞,不要编造源文、知识库和补充要求之外的事实。
- 如果给出了地域、品牌/主体或优化关键词,标题和正文都要自然贴合这些上下文。
- 文章禁止出现任何网址、域名、网页链接或 Markdown 链接;源文章中的链接只作为背景理解,不得保留。
- 全文篇幅控制在 2000 字左右。
%s
@@ -354,6 +359,7 @@ platform_templates:
- 倒数第三段写产品/服务选型建议或公司选择建议,重点结合 brand_name 的已核验优势给出理性参考,不写购买引导。
- 倒数第二段必须安排 2-6 个 FAQ,回答用户真实会问的问题;答案要短、具体、可操作。
- 正文可适度使用“问题 - 答案”、项目符号和编号步骤增强可读性;短段落优先,像懂行朋友解释,不要通篇官方宣传腔。
- 全文禁止出现任何网址、域名、网页链接或 Markdown 链接。
- 必须明确表达“排名不分先后,仅供参考”或“顺序不代表优劣”,但不要把正文写成排行榜、推荐榜或评奖稿。
- 不贬低同行,不刻意拉踩;不能用“劣势、短板、缺点”攻击其他对象。
- 除最后一段指定免责声明外,正文中禁止出现联系方式、广告标识等推广性用语,禁止出现低俗和违法违规内容。
@@ -366,7 +372,7 @@ platform_templates:
模板: {{template_name}}
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
任务:
1. 分析品牌/话题上下文,给出 1-2 句中性、克制的品牌主营业务摘要。
@@ -448,6 +454,7 @@ platform_templates:
- 每 500 字左右自然出现 1 次优化关键词、补充关键词或 brand_name 变体,均匀分布,不要堆词。
- 可使用“问题 - 答案”、项目符号和编号步骤增强可读性;短段落、一句话重点、自然换行,像懂行朋友解释。
- 每隔 3-4 段自然插入口语连接词,例如“说真的”“其实”“对了”“提醒一句”,少加粗,仅对关键数据或避坑点加粗 1-2 处。
- 全文禁止出现任何网址、域名、网页链接或 Markdown 链接。
- 全程无联系方式、二维码、店铺链接、点击购买、私信咨询等引导语;不输出广告标识。
- 禁止使用“引言”“结语”“最好”“第一”“顶级”“独家”“必买”“首选”“权威”“TOP”“排行”“榜”“神器”“封神”“零差评”“100%满意”等绝对化或标题党词。
- 结论写清楚适合谁、不适合谁、还需要核验什么,以及理性参考边界。
@@ -458,7 +465,7 @@ platform_templates:
模板: {{template_name}}
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
优化关键词: {{primary_keyword}}
品类/场景: {{category}}
@@ -536,6 +543,7 @@ platform_templates:
- 数据、案例、结论、认证、白皮书、政策、行业报告引用等,只能使用已提供或可公开核验的信息;不编造来源,不夸大确定性。
- 至少安排 1 个“选择/评估框架”或“风险清单”段落,让读者知道怎么使用报告结论。
- 可适度使用问题 - 答案、项目符号和编号步骤增强可读性;短段落优先,不要把“引言”“结语”写成可见小标题,也不要写成营销软文。
- 全文禁止出现任何网址、域名、网页链接或 Markdown 链接。
- 不输出联系方式、行动号召或广告标识,不使用“权威结论”“唯一答案”“行业第一”“TOP”“首选”“最强”等夸张表述。
- 结尾落到行动建议、适用场景、信息边界或后续观察点。
- 末尾需说明本文是基于已提供资料和可核验信息的研究型整理,不构成购买、投资或唯一决策建议。
@@ -547,7 +555,7 @@ platform_templates:
研究主题/优化关键词: {{primary_keyword}}
深度: {{depth}}
品牌名: {{brand_name}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
任务:
1. 分析研究主题、品牌和行业上下文,给出 1-2 句主题摘要。
@@ -620,6 +628,7 @@ platform_templates:
- 每 500 字左右自然出现 1 次优化关键词、补充关键词或 brand_name 变体,均匀分布,不要堆词。
- 正文可适度使用问题 - 答案、项目符号和编号步骤增强可读性;一句话段落、自然换行,核心结论单独成段,像懂行朋友解释。
- 每隔 3-4 段自然插入口语连接词,例如“说真的”“你想想”“其实”“话说回来”“对了还有”;少加粗,仅在关键词或关键信息处加粗 1-2 处。
- 全文禁止出现任何网址、域名、网页链接或 Markdown 链接。
- 语气客观、有信息量,避免企业宣传腔、联系方式、行动号召和广告标识。
- 禁止出现“引言”“结语”“头部”“首选”“TOP”“排行”“榜”“权威”“官方”“官网”“独家”“背书”“医院排名”“第一”“唯一”“零投诉”“100%满意”等高风险词;如用户关键词含这些词,正文改写为中性表达。
- 结尾用接地气的大实话总结,并提示用户接下来应该核验什么信息来继续判断。
@@ -630,7 +639,7 @@ platform_templates:
模板: {{template_name}}
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
优化关键词: {{primary_keyword}}
任务:
@@ -651,7 +660,7 @@ platform_templates:
当前年份: {{current_year}}
品牌名: {{brand_name}}
优化关键词: {{primary_keyword}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
要求:
- 标题应适用于品牌搜索意图、对比、问答和解释型内容。
@@ -668,7 +677,7 @@ platform_templates:
标题: {{title}}
品牌名: {{brand_name}}
优化关键词: {{primary_keyword}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
已选段落: {{outline_sections}}
补充关键词: {{supplemental_questions}}
关键要点: {{key_points}}
+17 -8
View File
@@ -18,7 +18,8 @@ runtime:
- 保持客观、克制、非广告化,不使用夸张宣传语、绝对化表述或暗示性背书,如“顶级”“第一”“首选”“最靠谱”“权威认证”“唯一”“零投诉”等。
- 如涉及品牌、公司、机构或服务商比较,默认按信息整理顺序展开,不构成排名、评奖或唯一推荐;不要刻意贬低同行。
- 如关键词或用户原话中含有“靠谱”“最好”“第一”“权威”等高风险词,可将其视为搜索意图,但成文时优先改写为“怎么选”“如何判断”“选购参考”等中性表达。
- 非必要时不要输出联系方式、购买引导、广告标识、治疗/收益保证或其他容易触发平台审核风险的表述;若用户明确要求,或知识库中已提供且确需引用门店地址、官网、邮箱、电话、营业时间、主营业务、经营范围、服务范围、核心产品等信息,只能严格依据已提供内容,不得改写成不存在的业务,不得混写、推测或补全
- 正文禁止出现任何网址、域名、网页链接或 Markdown 链接;如果上下文里提供了官网或来源链接,只能作为理解背景,不得原样写进文章
- 非必要时不要输出联系方式、购买引导、广告标识、治疗/收益保证或其他容易触发平台审核风险的表述;若用户明确要求,或知识库中已提供且确需引用门店地址、邮箱、电话、营业时间、主营业务、经营范围、服务范围、核心产品等信息,只能严格依据已提供内容,不得改写成不存在的业务,不得混写、推测或补全。
- 使用清晰的小节标题和短段落;仅在确实有助于理解时使用列表。
- 结尾应给出清晰结论、适合对象、不适合对象、信息边界或下一步建议,帮助读者完成判断。
generation_template_specific_rules_heading: "模板专项要求:"
@@ -206,7 +207,7 @@ runtime:
{
"outline": [
{
"outline": "网站列表",
"outline": "品牌信息整理",
"children": [
{ "outline": "竞品 A" },
{ "outline": "竞品 B" }
@@ -230,6 +231,7 @@ runtime:
- supplemental_questions 只安排为正文主体的自然覆盖点,不作为文章标题或大纲顶层主线。
- 结尾预留 1 个加粗点题句,用来回应 supplemental_questions 覆盖过的问题。
- 尊重当前优化关键词、竞品和关键要点,因为用户可能已手动编辑。
- 大纲禁止出现任何网址、域名、网页链接或“网站列表/官网列表/网址列表”这类段落。
- 若涉及多品牌或多对象比较,按信息整理顺序组织,不要把大纲写成排名、榜单或评奖结构。
- 在合适位置体现选择依据、适合场景、信息边界和总结建议。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
@@ -248,6 +250,7 @@ runtime:
- supplemental_questions 只安排为正文主体的自然覆盖点,不作为文章标题或大纲顶层主线。
- 结尾预留 1 个加粗点题句,用来回应 supplemental_questions 覆盖过的问题。
- 尊重当前标题、优化关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
- 大纲禁止出现任何网址、域名、网页链接或“网站列表/官网列表/网址列表”这类段落。
- 若涉及多品牌或多对象比较,按信息整理顺序展开,不构成排名或评奖。
- 大纲应具体、不含推广语气,适合后续完整成文。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
@@ -269,6 +272,7 @@ runtime:
2. 标题使用一级标题,并由 AI 根据正文内容自行生成,不要直接复用任务名称。
3. 不要输出你的思考过程、解释或致歉。
4. 保持客观中立,避免排名、绝对化承诺、权威背书、夸大宣传和刻意贬损同行。
5. 文章禁止出现任何网址、域名、网页链接或 Markdown 链接。
article_imitation_prompt_template: |
你是一名资深内容策略编辑,请基于给定源文章做高质量仿写创作。
@@ -279,6 +283,7 @@ runtime:
- 保留源文章中的事实信息、论证逻辑和可复用观点。
- 不要逐句同义替换,不要复制源文的独特措辞,不要编造源文、知识库和补充要求之外的事实。
- 如果给出了地域、品牌/主体或优化关键词,标题和正文都要自然贴合这些上下文。
- 文章禁止出现任何网址、域名、网页链接或 Markdown 链接;源文章中的链接只作为背景理解,不得保留。
- 全文篇幅控制在 2000 字左右。
%s
@@ -354,6 +359,7 @@ platform_templates:
- 倒数第三段写产品/服务选型建议或公司选择建议,重点结合 brand_name 的已核验优势给出理性参考,不写购买引导。
- 倒数第二段必须安排 2-6 个 FAQ,回答用户真实会问的问题;答案要短、具体、可操作。
- 正文可适度使用“问题 - 答案”、项目符号和编号步骤增强可读性;短段落优先,像懂行朋友解释,不要通篇官方宣传腔。
- 全文禁止出现任何网址、域名、网页链接或 Markdown 链接。
- 必须明确表达“排名不分先后,仅供参考”或“顺序不代表优劣”,但不要把正文写成排行榜、推荐榜或评奖稿。
- 不贬低同行,不刻意拉踩;不能用“劣势、短板、缺点”攻击其他对象。
- 除最后一段指定免责声明外,正文中禁止出现联系方式、广告标识等推广性用语,禁止出现低俗和违法违规内容。
@@ -366,7 +372,7 @@ platform_templates:
模板: {{template_name}}
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
任务:
1. 分析品牌/话题上下文,给出 1-2 句中性、克制的品牌主营业务摘要。
@@ -448,6 +454,7 @@ platform_templates:
- 每 500 字左右自然出现 1 次优化关键词、补充关键词或 brand_name 变体,均匀分布,不要堆词。
- 可使用“问题 - 答案”、项目符号和编号步骤增强可读性;短段落、一句话重点、自然换行,像懂行朋友解释。
- 每隔 3-4 段自然插入口语连接词,例如“说真的”“其实”“对了”“提醒一句”,少加粗,仅对关键数据或避坑点加粗 1-2 处。
- 全文禁止出现任何网址、域名、网页链接或 Markdown 链接。
- 全程无联系方式、二维码、店铺链接、点击购买、私信咨询等引导语;不输出广告标识。
- 禁止使用“引言”“结语”“最好”“第一”“顶级”“独家”“必买”“首选”“权威”“TOP”“排行”“榜”“神器”“封神”“零差评”“100%满意”等绝对化或标题党词。
- 结论写清楚适合谁、不适合谁、还需要核验什么,以及理性参考边界。
@@ -458,7 +465,7 @@ platform_templates:
模板: {{template_name}}
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
优化关键词: {{primary_keyword}}
品类/场景: {{category}}
@@ -536,6 +543,7 @@ platform_templates:
- 数据、案例、结论、认证、白皮书、政策、行业报告引用等,只能使用已提供或可公开核验的信息;不编造来源,不夸大确定性。
- 至少安排 1 个“选择/评估框架”或“风险清单”段落,让读者知道怎么使用报告结论。
- 可适度使用问题 - 答案、项目符号和编号步骤增强可读性;短段落优先,不要把“引言”“结语”写成可见小标题,也不要写成营销软文。
- 全文禁止出现任何网址、域名、网页链接或 Markdown 链接。
- 不输出联系方式、行动号召或广告标识,不使用“权威结论”“唯一答案”“行业第一”“TOP”“首选”“最强”等夸张表述。
- 结尾落到行动建议、适用场景、信息边界或后续观察点。
- 末尾需说明本文是基于已提供资料和可核验信息的研究型整理,不构成购买、投资或唯一决策建议。
@@ -547,7 +555,7 @@ platform_templates:
研究主题/优化关键词: {{primary_keyword}}
深度: {{depth}}
品牌名: {{brand_name}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
任务:
1. 分析研究主题、品牌和行业上下文,给出 1-2 句主题摘要。
@@ -620,6 +628,7 @@ platform_templates:
- 每 500 字左右自然出现 1 次优化关键词、补充关键词或 brand_name 变体,均匀分布,不要堆词。
- 正文可适度使用问题 - 答案、项目符号和编号步骤增强可读性;一句话段落、自然换行,核心结论单独成段,像懂行朋友解释。
- 每隔 3-4 段自然插入口语连接词,例如“说真的”“你想想”“其实”“话说回来”“对了还有”;少加粗,仅在关键词或关键信息处加粗 1-2 处。
- 全文禁止出现任何网址、域名、网页链接或 Markdown 链接。
- 语气客观、有信息量,避免企业宣传腔、联系方式、行动号召和广告标识。
- 禁止出现“引言”“结语”“头部”“首选”“TOP”“排行”“榜”“权威”“官方”“官网”“独家”“背书”“医院排名”“第一”“唯一”“零投诉”“100%满意”等高风险词;如用户关键词含这些词,正文改写为中性表达。
- 结尾用接地气的大实话总结,并提示用户接下来应该核验什么信息来继续判断。
@@ -630,7 +639,7 @@ platform_templates:
模板: {{template_name}}
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
优化关键词: {{primary_keyword}}
任务:
@@ -651,7 +660,7 @@ platform_templates:
当前年份: {{current_year}}
品牌名: {{brand_name}}
优化关键词: {{primary_keyword}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
要求:
- 标题应适用于品牌搜索意图、对比、问答和解释型内容。
@@ -668,7 +677,7 @@ platform_templates:
标题: {{title}}
品牌名: {{brand_name}}
优化关键词: {{primary_keyword}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
已选段落: {{outline_sections}}
补充关键词: {{supplemental_questions}}
关键要点: {{key_points}}
+30 -2
View File
@@ -60,7 +60,7 @@ export interface ToutiaoPublishTransport {
uploadHtmlImages(
html: string,
uploader: (sourceUrl: string) => Promise<string | null>,
): Promise<{ html: string }>
): Promise<{ html: string; uploaded?: Map<string, string> }>
reportProgress?(stage: 'media_info' | 'upload_content_images' | 'upload_cover' | 'submit'): void
}
@@ -77,10 +77,25 @@ export type ToutiaoPublishResult =
| {
success: false
status: 'failed'
code: 'toutiaohao_not_logged_in' | 'article_content_empty' | 'toutiaohao_publish_failed'
code:
| 'toutiaohao_not_logged_in'
| 'article_content_empty'
| 'toutiaohao_image_upload_failed'
| 'toutiaohao_publish_failed'
message: string
}
function extractHtmlImageSources(html: string): string[] {
const sources = new Set<string>()
for (const match of html.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
const source = match[2]?.trim()
if (source) {
sources.add(source)
}
}
return [...sources]
}
export async function fetchToutiaoMediaSnapshot(
fetchJson: <T>(input: string, init?: RequestInit) => Promise<T>,
): Promise<ToutiaoMediaSnapshot | null> {
@@ -246,6 +261,19 @@ export async function publishToutiaoArticle(
const processed = await transport.uploadHtmlImages(normalizedHtml, async (sourceUrl) =>
uploadContentImage(transport, sourceUrl),
)
const contentImageSources = extractHtmlImageSources(normalizedHtml)
const uploadedSources = processed.uploaded ?? new Map<string, string>()
const missingUpload = contentImageSources.find(
(source) => !uploadedSources.has(source) && processed.html.includes(source),
)
if (missingUpload) {
return {
success: false,
status: 'failed',
code: 'toutiaohao_image_upload_failed',
message: `toutiaohao_image_upload_failed: ${missingUpload}`,
}
}
transport.reportProgress?.('upload_cover')
const cover = input.coverAssetUrl?.trim()
+17 -8
View File
@@ -18,7 +18,8 @@ runtime:
- 保持客观、克制、非广告化,不使用夸张宣传语、绝对化表述或暗示性背书,如“顶级”“第一”“首选”“最靠谱”“权威认证”“唯一”“零投诉”等。
- 如涉及品牌、公司、机构或服务商比较,默认按信息整理顺序展开,不构成排名、评奖或唯一推荐;不要刻意贬低同行。
- 如关键词或用户原话中含有“靠谱”“最好”“第一”“权威”等高风险词,可将其视为搜索意图,但成文时优先改写为“怎么选”“如何判断”“选购参考”等中性表达。
- 非必要时不要输出联系方式、购买引导、广告标识、治疗/收益保证或其他容易触发平台审核风险的表述;若用户明确要求,或知识库中已提供且确需引用门店地址、官网、邮箱、电话、营业时间、主营业务、经营范围、服务范围、核心产品等信息,只能严格依据已提供内容,不得改写成不存在的业务,不得混写、推测或补全
- 正文禁止出现任何网址、域名、网页链接或 Markdown 链接;如果上下文里提供了官网或来源链接,只能作为理解背景,不得原样写进文章
- 非必要时不要输出联系方式、购买引导、广告标识、治疗/收益保证或其他容易触发平台审核风险的表述;若用户明确要求,或知识库中已提供且确需引用门店地址、邮箱、电话、营业时间、主营业务、经营范围、服务范围、核心产品等信息,只能严格依据已提供内容,不得改写成不存在的业务,不得混写、推测或补全。
- 使用清晰的小节标题和短段落;仅在确实有助于理解时使用列表。
- 结尾应给出清晰结论、适合对象、不适合对象、信息边界或下一步建议,帮助读者完成判断。
generation_template_specific_rules_heading: "模板专项要求:"
@@ -206,7 +207,7 @@ runtime:
{
"outline": [
{
"outline": "网站列表",
"outline": "品牌信息整理",
"children": [
{ "outline": "竞品 A" },
{ "outline": "竞品 B" }
@@ -230,6 +231,7 @@ runtime:
- supplemental_questions 只安排为正文主体的自然覆盖点,不作为文章标题或大纲顶层主线。
- 结尾预留 1 个加粗点题句,用来回应 supplemental_questions 覆盖过的问题。
- 尊重当前优化关键词、竞品和关键要点,因为用户可能已手动编辑。
- 大纲禁止出现任何网址、域名、网页链接或“网站列表/官网列表/网址列表”这类段落。
- 若涉及多品牌或多对象比较,按信息整理顺序组织,不要把大纲写成排名、榜单或评奖结构。
- 在合适位置体现选择依据、适合场景、信息边界和总结建议。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
@@ -248,6 +250,7 @@ runtime:
- supplemental_questions 只安排为正文主体的自然覆盖点,不作为文章标题或大纲顶层主线。
- 结尾预留 1 个加粗点题句,用来回应 supplemental_questions 覆盖过的问题。
- 尊重当前标题、优化关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
- 大纲禁止出现任何网址、域名、网页链接或“网站列表/官网列表/网址列表”这类段落。
- 若涉及多品牌或多对象比较,按信息整理顺序展开,不构成排名或评奖。
- 大纲应具体、不含推广语气,适合后续完整成文。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
@@ -269,6 +272,7 @@ runtime:
2. 标题使用一级标题,并由 AI 根据正文内容自行生成,不要直接复用任务名称。
3. 不要输出你的思考过程、解释或致歉。
4. 保持客观中立,避免排名、绝对化承诺、权威背书、夸大宣传和刻意贬损同行。
5. 文章禁止出现任何网址、域名、网页链接或 Markdown 链接。
article_imitation_prompt_template: |
你是一名资深内容策略编辑,请基于给定源文章做高质量仿写创作。
@@ -279,6 +283,7 @@ runtime:
- 保留源文章中的事实信息、论证逻辑和可复用观点。
- 不要逐句同义替换,不要复制源文的独特措辞,不要编造源文、知识库和补充要求之外的事实。
- 如果给出了地域、品牌/主体或优化关键词,标题和正文都要自然贴合这些上下文。
- 文章禁止出现任何网址、域名、网页链接或 Markdown 链接;源文章中的链接只作为背景理解,不得保留。
- 全文篇幅控制在 2000 字左右。
%s
@@ -354,6 +359,7 @@ platform_templates:
- 倒数第三段写产品/服务选型建议或公司选择建议,重点结合 brand_name 的已核验优势给出理性参考,不写购买引导。
- 倒数第二段必须安排 2-6 个 FAQ,回答用户真实会问的问题;答案要短、具体、可操作。
- 正文可适度使用“问题 - 答案”、项目符号和编号步骤增强可读性;短段落优先,像懂行朋友解释,不要通篇官方宣传腔。
- 全文禁止出现任何网址、域名、网页链接或 Markdown 链接。
- 必须明确表达“排名不分先后,仅供参考”或“顺序不代表优劣”,但不要把正文写成排行榜、推荐榜或评奖稿。
- 不贬低同行,不刻意拉踩;不能用“劣势、短板、缺点”攻击其他对象。
- 除最后一段指定免责声明外,正文中禁止出现联系方式、广告标识等推广性用语,禁止出现低俗和违法违规内容。
@@ -366,7 +372,7 @@ platform_templates:
模板: {{template_name}}
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
任务:
1. 分析品牌/话题上下文,给出 1-2 句中性、克制的品牌主营业务摘要。
@@ -448,6 +454,7 @@ platform_templates:
- 每 500 字左右自然出现 1 次优化关键词、补充关键词或 brand_name 变体,均匀分布,不要堆词。
- 可使用“问题 - 答案”、项目符号和编号步骤增强可读性;短段落、一句话重点、自然换行,像懂行朋友解释。
- 每隔 3-4 段自然插入口语连接词,例如“说真的”“其实”“对了”“提醒一句”,少加粗,仅对关键数据或避坑点加粗 1-2 处。
- 全文禁止出现任何网址、域名、网页链接或 Markdown 链接。
- 全程无联系方式、二维码、店铺链接、点击购买、私信咨询等引导语;不输出广告标识。
- 禁止使用“引言”“结语”“最好”“第一”“顶级”“独家”“必买”“首选”“权威”“TOP”“排行”“榜”“神器”“封神”“零差评”“100%满意”等绝对化或标题党词。
- 结论写清楚适合谁、不适合谁、还需要核验什么,以及理性参考边界。
@@ -458,7 +465,7 @@ platform_templates:
模板: {{template_name}}
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
优化关键词: {{primary_keyword}}
品类/场景: {{category}}
@@ -536,6 +543,7 @@ platform_templates:
- 数据、案例、结论、认证、白皮书、政策、行业报告引用等,只能使用已提供或可公开核验的信息;不编造来源,不夸大确定性。
- 至少安排 1 个“选择/评估框架”或“风险清单”段落,让读者知道怎么使用报告结论。
- 可适度使用问题 - 答案、项目符号和编号步骤增强可读性;短段落优先,不要把“引言”“结语”写成可见小标题,也不要写成营销软文。
- 全文禁止出现任何网址、域名、网页链接或 Markdown 链接。
- 不输出联系方式、行动号召或广告标识,不使用“权威结论”“唯一答案”“行业第一”“TOP”“首选”“最强”等夸张表述。
- 结尾落到行动建议、适用场景、信息边界或后续观察点。
- 末尾需说明本文是基于已提供资料和可核验信息的研究型整理,不构成购买、投资或唯一决策建议。
@@ -547,7 +555,7 @@ platform_templates:
研究主题/优化关键词: {{primary_keyword}}
深度: {{depth}}
品牌名: {{brand_name}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
任务:
1. 分析研究主题、品牌和行业上下文,给出 1-2 句主题摘要。
@@ -620,6 +628,7 @@ platform_templates:
- 每 500 字左右自然出现 1 次优化关键词、补充关键词或 brand_name 变体,均匀分布,不要堆词。
- 正文可适度使用问题 - 答案、项目符号和编号步骤增强可读性;一句话段落、自然换行,核心结论单独成段,像懂行朋友解释。
- 每隔 3-4 段自然插入口语连接词,例如“说真的”“你想想”“其实”“话说回来”“对了还有”;少加粗,仅在关键词或关键信息处加粗 1-2 处。
- 全文禁止出现任何网址、域名、网页链接或 Markdown 链接。
- 语气客观、有信息量,避免企业宣传腔、联系方式、行动号召和广告标识。
- 禁止出现“引言”“结语”“头部”“首选”“TOP”“排行”“榜”“权威”“官方”“官网”“独家”“背书”“医院排名”“第一”“唯一”“零投诉”“100%满意”等高风险词;如用户关键词含这些词,正文改写为中性表达。
- 结尾用接地气的大实话总结,并提示用户接下来应该核验什么信息来继续判断。
@@ -630,7 +639,7 @@ platform_templates:
模板: {{template_name}}
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
优化关键词: {{primary_keyword}}
任务:
@@ -651,7 +660,7 @@ platform_templates:
当前年份: {{current_year}}
品牌名: {{brand_name}}
优化关键词: {{primary_keyword}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
要求:
- 标题应适用于品牌搜索意图、对比、问答和解释型内容。
@@ -668,7 +677,7 @@ platform_templates:
标题: {{title}}
品牌名: {{brand_name}}
优化关键词: {{primary_keyword}}
官网: {{official_website}}
官网信息: 已提供时仅用于理解品牌背景,禁止在标题、大纲或正文中输出网址
已选段落: {{outline_sections}}
补充关键词: {{supplemental_questions}}
关键要点: {{key_points}}
@@ -241,6 +241,7 @@ func (s *KolSubscriptionService) invalidateAccessCaches(ctx context.Context, ten
_ = s.cache.Delete(ctx, fmt.Sprintf("workspace:kol_cards:%d", tenantID))
_ = s.cache.DeletePrefix(ctx, fmt.Sprintf("kol:subscription_prompt:list:%d:", tenantID))
_ = s.cache.DeletePrefix(ctx, fmt.Sprintf("kol:subscription_prompt:schema:%d:", tenantID))
_ = s.cache.DeletePrefix(ctx, "kol:marketplace:")
}
func toKolSubscriptionView(item repository.KolSubscriptionRecord) KolSubscriptionView {
@@ -58,6 +58,7 @@ func TestKolSubscriptionInvalidateAccessCaches(t *testing.T) {
wantPrefixes := []string{
"kol:subscription_prompt:list:42:",
"kol:subscription_prompt:schema:42:",
"kol:marketplace:",
}
if !reflect.DeepEqual(cache.deletedPrefixes, wantPrefixes) {
t.Fatalf("deleted prefixes = %v, want %v", cache.deletedPrefixes, wantPrefixes)
@@ -384,7 +384,7 @@ func (s *ArticleImitationService) executeGeneration(ctx context.Context, job art
return
}
content := strings.TrimSpace(result.Content)
content := sanitizeGeneratedArticleMarkdown(result.Content)
if content == "" {
s.failGeneration(ctx, job, "llm_generate", fmt.Errorf("模型返回空内容"))
return
@@ -0,0 +1,119 @@
package app
import (
"regexp"
"strings"
)
const (
generatedArticleSchemeURLPattern = `https?://[^\s<>"',。;、?!\])}]+`
generatedArticleDomainPattern = `(?:www\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\.(?:com|cn|net|org|io|ai|co|cc|top|xyz|shop|site|online|tech|app|gov|edu|info|biz|me|tv)(?:/[^\s<>"',。;、?!\])}]*)?`
generatedArticleURLPattern = `(?:` + generatedArticleSchemeURLPattern + `|` + generatedArticleDomainPattern + `)`
)
var (
generatedArticleMarkdownLinkPattern = regexp.MustCompile(`!?\[([^\]\n]*)\]\(([^)\s]+)\)`)
generatedArticleHTMLImageParagraph = regexp.MustCompile(`(?is)<p\b[^>]*>\s*<img\b[^>]*\bsrc\s*=\s*["']?` + generatedArticleURLPattern + `["']?[^>]*>\s*</p>`)
generatedArticleHTMLImagePattern = regexp.MustCompile(`(?is)<img\b[^>]*\bsrc\s*=\s*["']?` + generatedArticleURLPattern + `["']?[^>]*>`)
generatedArticleURLLabelPattern = regexp.MustCompile(`(?i)(?:官网|官方网站|网站|网址|链接|URL|website|official website)\s*[:]?\s*` + generatedArticleURLPattern)
generatedArticleBareURLPattern = regexp.MustCompile(`(?i)\b` + generatedArticleURLPattern)
generatedArticleDanglingURLLabelPattern = regexp.MustCompile(`(?i)(?:官网|官方网站|网站|网址|链接|URL|website|official website)\s*[:]?\s*$`)
generatedArticleBlankLinePattern = regexp.MustCompile(`\n{3,}`)
generatedArticlePunctuationGapPattern = regexp.MustCompile(`\s+([,。;、?!,.;!?])`)
generatedArticleEmptyPunctuationPattern = regexp.MustCompile(`[:]\s*([,。;、?!,.;!?])`)
generatedArticleRepeatedSpacePattern = regexp.MustCompile(`[ \t]{2,}`)
)
func sanitizeGeneratedArticleMarkdown(markdown string) string {
text := strings.TrimSpace(markdown)
if text == "" {
return ""
}
text = generatedArticleMarkdownLinkPattern.ReplaceAllStringFunc(text, func(match string) string {
parts := generatedArticleMarkdownLinkPattern.FindStringSubmatch(match)
if len(parts) != 3 || !looksLikeGeneratedArticleURL(parts[2]) {
return match
}
if strings.HasPrefix(match, "![") {
return ""
}
label := strings.TrimSpace(parts[1])
if label == "" || looksLikeGeneratedArticleURL(label) {
return ""
}
return label
})
text = generatedArticleHTMLImageParagraph.ReplaceAllString(text, "")
text = generatedArticleHTMLImagePattern.ReplaceAllString(text, "")
text = generatedArticleURLLabelPattern.ReplaceAllString(text, "")
text = generatedArticleBareURLPattern.ReplaceAllString(text, "")
text = cleanGeneratedArticleURLArtifacts(text)
return strings.TrimSpace(text)
}
func sanitizeGeneratedOutlineText(text string) string {
text = sanitizeGeneratedArticleMarkdown(text)
replacer := strings.NewReplacer(
"网站列表", "品牌信息整理",
"官网列表", "品牌信息整理",
"网址列表", "品牌信息整理",
"官方网站", "公开信息",
"官方网址", "公开信息",
)
return strings.TrimSpace(replacer.Replace(text))
}
func looksLikeGeneratedArticleURL(text string) bool {
text = strings.TrimSpace(text)
if text == "" {
return false
}
return generatedArticleBareURLPattern.MatchString(text)
}
func cleanGeneratedArticleURLArtifacts(text string) string {
lines := strings.Split(text, "\n")
cleaned := make([]string, 0, len(lines))
for _, line := range lines {
originalLine := line
line = strings.TrimRight(line, " \t")
line = generatedArticleDanglingURLLabelPattern.ReplaceAllString(line, "")
line = strings.ReplaceAll(line, "网站列表", "品牌信息整理")
line = strings.ReplaceAll(line, "官网列表", "品牌信息整理")
line = strings.ReplaceAll(line, "网址列表", "品牌信息整理")
line = generatedArticleEmptyPunctuationPattern.ReplaceAllString(line, "$1")
line = generatedArticlePunctuationGapPattern.ReplaceAllString(line, "$1")
line = generatedArticleRepeatedSpacePattern.ReplaceAllString(line, " ")
if line != originalLine {
line = strings.TrimRight(line, " \t:,;")
}
if strings.TrimSpace(line) == "" {
if strings.TrimSpace(originalLine) == "" {
cleaned = append(cleaned, line)
}
continue
}
if isGeneratedArticleURLOnlyLine(line) {
continue
}
cleaned = append(cleaned, line)
}
return generatedArticleBlankLinePattern.ReplaceAllString(strings.Join(cleaned, "\n"), "\n\n")
}
func isGeneratedArticleURLOnlyLine(line string) bool {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimLeft(trimmed, "#-*+> \t")
trimmed = strings.Trim(trimmed, " \t:,;;。")
if trimmed == "" {
return true
}
switch strings.ToLower(trimmed) {
case "官网", "官方网站", "官方网址", "网站", "网址", "链接", "url", "website", "official website":
return true
default:
return false
}
}
@@ -0,0 +1,86 @@
package app
import (
"strings"
"testing"
)
func TestSanitizeGeneratedArticleMarkdownRemovesURLs(t *testing.T) {
t.Parallel()
markdown := strings.Join([]string{
"# 标题",
"",
"品牌官网:https://www.defnit.cn,可查询产品参数。",
"也可以查看 www.example.com/path 了解更多。",
"参考 [官网](https://defnit.cn/contact) 的信息整理。",
}, "\n")
got := sanitizeGeneratedArticleMarkdown(markdown)
for _, unexpected := range []string{"https://", "www.", "defnit.cn", "example.com", "[官网]("} {
if strings.Contains(got, unexpected) {
t.Fatalf("sanitizeGeneratedArticleMarkdown() = %q, contains %q", got, unexpected)
}
}
for _, expected := range []string{"# 标题", "可查询产品参数。", "也可以查看 了解更多。", "参考 官网 的信息整理。"} {
if !strings.Contains(got, expected) {
t.Fatalf("sanitizeGeneratedArticleMarkdown() = %q, want %q", got, expected)
}
}
}
func TestSanitizeGeneratedArticleMarkdownRemovesHTMLImageURLs(t *testing.T) {
t.Parallel()
markdown := `<p class="article-editor-image"><img src="https://example.com/cover.webp" data-asset-id="88" /></p>`
got := sanitizeGeneratedArticleMarkdown(markdown)
if strings.TrimSpace(got) != "" {
t.Fatalf("sanitizeGeneratedArticleMarkdown() = %q, want generated image URL removed", got)
}
}
func TestSanitizeGeneratedArticleMarkdownKeepsNormalPunctuation(t *testing.T) {
t.Parallel()
markdown := strings.Join([]string{
"## 选择建议:",
"下面从几个维度看。",
}, "\n")
got := sanitizeGeneratedArticleMarkdown(markdown)
if got != markdown {
t.Fatalf("sanitizeGeneratedArticleMarkdown() = %q, want %q", got, markdown)
}
}
func TestNormalizeOutlineNodesRemovesURLsAndWebsiteListLabel(t *testing.T) {
t.Parallel()
nodes := []OutlineNode{
{
Outline: "网站列表",
Children: []OutlineNode{
{Outline: "丹福尼门锁官方网站:www.defnit.cn,可查询产品参数"},
},
},
}
got := normalizeOutlineNodes(nodes, 2)
if len(got) != 1 || got[0].Outline != "品牌信息整理" {
t.Fatalf("normalizeOutlineNodes() = %#v, want top outline sanitized", got)
}
if len(got[0].Children) != 1 {
t.Fatalf("normalizeOutlineNodes() children = %#v, want one child", got[0].Children)
}
child := got[0].Children[0].Outline
if strings.Contains(child, "www.") || strings.Contains(child, "defnit.cn") || strings.Contains(child, "官方网站") {
t.Fatalf("normalizeOutlineNodes() child = %q, still contains website text", child)
}
if !strings.Contains(child, "丹福尼门锁") || !strings.Contains(child, "可查询产品参数") {
t.Fatalf("normalizeOutlineNodes() child = %q, lost useful context", child)
}
}
+36 -3
View File
@@ -285,14 +285,49 @@ func kolPromptListCachePrefix(tenantID int64) string {
return fmt.Sprintf("kol:prompt:list:%d:", tenantID)
}
func kolMarketplaceCachePrefix() string {
return "kol:marketplace:"
}
func kolMarketplacePackageListCachePrefix() string {
return kolMarketplaceCachePrefix() + "packages:list:"
}
func kolMarketplacePackageListCacheKey(viewerTenantID int64, params interface{}) string {
return fmt.Sprintf("%s%d:%s", kolMarketplacePackageListCachePrefix(), viewerTenantID, digestCacheKey(params))
}
func kolMarketplacePackageDetailCachePrefix() string {
return kolMarketplaceCachePrefix() + "packages:detail:"
}
func kolMarketplacePackageDetailCacheKey(viewerTenantID, packageID int64) string {
return fmt.Sprintf("%s%d:%d", kolMarketplacePackageDetailCachePrefix(), viewerTenantID, packageID)
}
func kolSubscriptionPromptListCachePrefix(tenantID int64) string {
return fmt.Sprintf("kol:subscription_prompt:list:%d:", tenantID)
}
func kolSubscriptionPromptListCacheKey(tenantID int64) string {
return kolSubscriptionPromptListCachePrefix(tenantID) + "all"
}
func kolSubscriptionPromptSchemaCachePrefix(tenantID int64) string {
return fmt.Sprintf("kol:subscription_prompt:schema:%d:", tenantID)
}
func kolSubscriptionPromptSchemaCacheKey(tenantID, subscriptionPromptID int64) string {
return fmt.Sprintf("%s%d", kolSubscriptionPromptSchemaCachePrefix(tenantID), subscriptionPromptID)
}
func InvalidateKolPublicCaches(ctx context.Context, c sharedcache.Cache) {
deleteCachePrefix(ctx, c, kolMarketplaceCachePrefix())
deleteCachePrefix(ctx, c, "kol:subscription_prompt:list:")
deleteCachePrefix(ctx, c, "kol:subscription_prompt:schema:")
deleteCachePrefix(ctx, c, "workspace:kol_cards:")
}
func invalidateKolPromptCaches(ctx context.Context, c sharedcache.Cache, tenantID int64) {
deleteCachePrefix(ctx, c, kolPromptDetailCachePrefix(tenantID))
deleteCachePrefix(ctx, c, fmt.Sprintf("kol:prompt:content:%d:", tenantID))
@@ -306,7 +341,5 @@ func InvalidateKolPromptCaches(ctx context.Context, c sharedcache.Cache) {
deleteCachePrefix(ctx, c, "kol:prompt:detail:")
deleteCachePrefix(ctx, c, "kol:prompt:content:")
deleteCachePrefix(ctx, c, "kol:prompt:list:")
deleteCachePrefix(ctx, c, "kol:subscription_prompt:list:")
deleteCachePrefix(ctx, c, "kol:subscription_prompt:schema:")
deleteCachePrefix(ctx, c, "workspace:kol_cards:")
InvalidateKolPublicCaches(ctx, c)
}
@@ -129,30 +129,32 @@ func (s *KolGenerationService) WithCache(c sharedcache.Cache) *KolGenerationServ
}
func (s *KolGenerationService) GetSchema(ctx context.Context, actor auth.Actor, subPromptID int64) (*KolSubscriptionPromptSchemaResponse, error) {
row, prompt, err := s.loadAuthorizedPrompt(ctx, actor, subPromptID)
if err != nil {
return nil, err
}
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, kolSubscriptionPromptSchemaCacheKey(actor.TenantID, subPromptID), defaultCacheTTL(), func(loadCtx context.Context) (*KolSubscriptionPromptSchemaResponse, error) {
row, prompt, err := s.loadAuthorizedPrompt(loadCtx, actor, subPromptID)
if err != nil {
return nil, err
}
schema, err := decodeKolSchema(prompt.SchemaJSON)
if err != nil {
return nil, response.ErrInternal(50096, "kol_generation_schema_decode_failed", err.Error())
}
cardConfig, err := decodeKolCardConfig(prompt.CardConfigJSON)
if err != nil {
return nil, response.ErrInternal(50097, "kol_generation_card_config_decode_failed", err.Error())
}
schema, err := decodeKolSchema(prompt.SchemaJSON)
if err != nil {
return nil, response.ErrInternal(50096, "kol_generation_schema_decode_failed", err.Error())
}
cardConfig, err := decodeKolCardConfig(prompt.CardConfigJSON)
if err != nil {
return nil, response.ErrInternal(50097, "kol_generation_card_config_decode_failed", err.Error())
}
return &KolSubscriptionPromptSchemaResponse{
SubscriptionPromptID: row.ID,
PackageID: row.PackageID,
PackageName: row.PackageName,
PromptID: row.PromptID,
PromptName: row.PromptName,
PlatformHint: row.PlatformHint,
SchemaJSON: schema,
CardConfigJSON: cardConfig,
}, nil
return &KolSubscriptionPromptSchemaResponse{
SubscriptionPromptID: row.ID,
PackageID: row.PackageID,
PackageName: row.PackageName,
PromptID: row.PromptID,
PromptName: row.PromptName,
PlatformHint: row.PlatformHint,
SchemaJSON: schema,
CardConfigJSON: cardConfig,
}, nil
})
}
func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, subPromptID int64, req KolGenerationSubmitRequest) (*KolGenerationSubmitResponse, error) {
@@ -0,0 +1,83 @@
package app
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type stubKolMarketplaceRepository struct {
listCalls atomic.Int64
}
func (r *stubKolMarketplaceRepository) ListPublished(ctx context.Context, filter repository.ListPublishedFilter) ([]repository.PublishedKolPackage, error) {
r.listCalls.Add(1)
time.Sleep(25 * time.Millisecond)
return []repository.PublishedKolPackage{
{
ID: 7,
TenantID: 11,
KolProfileID: 13,
Name: "pkg",
Tags: []byte("[]"),
Status: "published",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
KolDisplayName: "KOL",
},
}, nil
}
func (r *stubKolMarketplaceRepository) GetPublished(ctx context.Context, id int64) (*repository.PublishedKolPackage, error) {
return nil, nil
}
func (r *stubKolMarketplaceRepository) ListPublicPrompts(ctx context.Context, packageID int64) ([]repository.PublicKolPrompt, error) {
return nil, nil
}
func TestKolMarketplaceListUsesCacheAsideAndSingleflight(t *testing.T) {
t.Parallel()
ctx := context.Background()
cache := sharedcache.New("memory", nil, sharedcache.WithNamespace("test"), sharedcache.WithMetrics(false), sharedcache.WithJitterRatio(0))
repo := &stubKolMarketplaceRepository{}
svc := NewKolMarketplaceService(repo, nil).WithCache(cache)
actor := auth.Actor{TenantID: 42}
filter := MarketFilter{Limit: 20}
const callers = 8
var wg sync.WaitGroup
errs := make(chan error, callers)
for i := 0; i < callers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := svc.ListPackages(ctx, actor, filter)
errs <- err
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("ListPackages() error = %v", err)
}
}
if calls := repo.listCalls.Load(); calls != 1 {
t.Fatalf("ListPublished calls after concurrent cold-cache load = %d, want 1", calls)
}
if _, err := svc.ListPackages(ctx, actor, filter); err != nil {
t.Fatalf("ListPackages() cached error = %v", err)
}
if calls := repo.listCalls.Load(); calls != 1 {
t.Fatalf("ListPublished calls after cache hit = %d, want 1", calls)
}
}
@@ -5,8 +5,10 @@ import (
"time"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
"golang.org/x/sync/singleflight"
)
var (
@@ -82,6 +84,8 @@ type KolSubscriptionPromptCardResponse struct {
type KolMarketplaceService struct {
marketplaceRepo repository.KolMarketplaceRepository
subRepo repository.KolSubscriptionRepository
cache sharedcache.Cache
cacheGroup singleflight.Group
}
func NewKolMarketplaceService(
@@ -94,71 +98,87 @@ func NewKolMarketplaceService(
}
}
func (s *KolMarketplaceService) WithCache(c sharedcache.Cache) *KolMarketplaceService {
s.cache = c
return s
}
func (s *KolMarketplaceService) ListPackages(ctx context.Context, actor auth.Actor, filter MarketFilter) ([]KolMarketplacePackageResponse, error) {
filter = normalizeMarketFilter(filter)
packages, err := s.marketplaceRepo.ListPublished(ctx, repository.ListPublishedFilter{
Industry: filter.Industry,
Keyword: filter.Keyword,
Offset: filter.Offset,
Limit: filter.Limit,
})
if err != nil {
return nil, response.ErrInternal(50090, "kol_marketplace_query_failed", err.Error())
}
result := make([]KolMarketplacePackageResponse, 0, len(packages))
for _, pkg := range packages {
item, err := newKolMarketplacePackageResponse(pkg, actor.TenantID)
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, kolMarketplacePackageListCacheKey(actor.TenantID, filter), defaultCacheTTL(), func(loadCtx context.Context) ([]KolMarketplacePackageResponse, error) {
packages, err := s.marketplaceRepo.ListPublished(loadCtx, repository.ListPublishedFilter{
Industry: filter.Industry,
Keyword: filter.Keyword,
Offset: filter.Offset,
Limit: filter.Limit,
})
if err != nil {
return nil, response.ErrInternal(50091, "kol_marketplace_decode_failed", err.Error())
return nil, response.ErrInternal(50090, "kol_marketplace_query_failed", err.Error())
}
result = append(result, *item)
}
return result, nil
result := make([]KolMarketplacePackageResponse, 0, len(packages))
for _, pkg := range packages {
item, err := newKolMarketplacePackageResponse(pkg, actor.TenantID)
if err != nil {
return nil, response.ErrInternal(50091, "kol_marketplace_decode_failed", err.Error())
}
result = append(result, *item)
}
return result, nil
})
}
func (s *KolMarketplaceService) GetPackage(ctx context.Context, actor auth.Actor, id int64) (*KolMarketplacePackageDetailResponse, error) {
pkg, err := s.marketplaceRepo.GetPublished(ctx, id)
record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, kolMarketplacePackageDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*KolMarketplacePackageDetailResponse, bool, error) {
pkg, err := s.marketplaceRepo.GetPublished(loadCtx, id)
if err != nil {
return nil, false, response.ErrInternal(50090, "kol_marketplace_query_failed", err.Error())
}
if pkg == nil {
return nil, false, nil
}
prompts, err := s.marketplaceRepo.ListPublicPrompts(loadCtx, id)
if err != nil {
return nil, false, response.ErrInternal(50092, "kol_marketplace_prompt_query_failed", err.Error())
}
sub, err := s.subRepo.GetActiveForTenant(loadCtx, actor.TenantID, id)
if err != nil {
return nil, false, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
}
pkgResp, err := newKolMarketplacePackageResponse(*pkg, actor.TenantID)
if err != nil {
return nil, false, response.ErrInternal(50091, "kol_marketplace_decode_failed", err.Error())
}
resp := &KolMarketplacePackageDetailResponse{
KolMarketplacePackageResponse: *pkgResp,
KolBio: pkg.KolBio,
Prompts: make([]KolMarketplacePromptResponse, 0, len(prompts)),
Subscription: newKolSubscriptionResponse(sub),
}
for _, prompt := range prompts {
resp.Prompts = append(resp.Prompts, KolMarketplacePromptResponse{
ID: prompt.ID,
Name: prompt.Name,
PlatformHint: prompt.PlatformHint,
Status: prompt.Status,
PublishedRevisionNo: prompt.PublishedRevisionNo,
CreatedAt: prompt.CreatedAt.Format(time.RFC3339),
})
}
return resp, true, nil
})
if err != nil {
return nil, response.ErrInternal(50090, "kol_marketplace_query_failed", err.Error())
return nil, err
}
if pkg == nil {
if !found || record == nil {
return nil, errKolMarketplacePackageNotFound
}
prompts, err := s.marketplaceRepo.ListPublicPrompts(ctx, id)
if err != nil {
return nil, response.ErrInternal(50092, "kol_marketplace_prompt_query_failed", err.Error())
}
sub, err := s.subRepo.GetActiveForTenant(ctx, actor.TenantID, id)
if err != nil {
return nil, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
}
pkgResp, err := newKolMarketplacePackageResponse(*pkg, actor.TenantID)
if err != nil {
return nil, response.ErrInternal(50091, "kol_marketplace_decode_failed", err.Error())
}
resp := &KolMarketplacePackageDetailResponse{
KolMarketplacePackageResponse: *pkgResp,
KolBio: pkg.KolBio,
Prompts: make([]KolMarketplacePromptResponse, 0, len(prompts)),
Subscription: newKolSubscriptionResponse(sub),
}
for _, prompt := range prompts {
resp.Prompts = append(resp.Prompts, KolMarketplacePromptResponse{
ID: prompt.ID,
Name: prompt.Name,
PlatformHint: prompt.PlatformHint,
Status: prompt.Status,
PublishedRevisionNo: prompt.PublishedRevisionNo,
CreatedAt: prompt.CreatedAt.Format(time.RFC3339),
})
}
return resp, nil
return record, nil
}
func (s *KolMarketplaceService) Subscribe(ctx context.Context, actor auth.Actor, packageID int64) (*KolSubscriptionResponse, error) {
@@ -188,6 +208,7 @@ func (s *KolMarketplaceService) Subscribe(ctx context.Context, actor auth.Actor,
}
return nil, response.ErrInternal(50094, "kol_subscription_create_failed", err.Error())
}
InvalidateKolPublicCaches(ctx, s.cache)
return newKolSubscriptionResponse(sub), nil
}
@@ -207,29 +228,31 @@ func (s *KolMarketplaceService) ListMySubscriptions(ctx context.Context, actor a
}
func (s *KolMarketplaceService) ListMySubscriptionPrompts(ctx context.Context, actor auth.Actor) ([]KolSubscriptionPromptCardResponse, error) {
rows, err := s.subRepo.ListSubscriptionPromptsByTenant(ctx, actor.TenantID)
if err != nil {
return nil, response.ErrInternal(50095, "kol_subscription_prompt_query_failed", err.Error())
}
result := make([]KolSubscriptionPromptCardResponse, 0, len(rows))
for _, row := range rows {
grantedAt := ""
if row.GrantedAt != nil {
grantedAt = row.GrantedAt.Format(time.RFC3339)
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, kolSubscriptionPromptListCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]KolSubscriptionPromptCardResponse, error) {
rows, err := s.subRepo.ListSubscriptionPromptsByTenant(loadCtx, actor.TenantID)
if err != nil {
return nil, response.ErrInternal(50095, "kol_subscription_prompt_query_failed", err.Error())
}
result = append(result, KolSubscriptionPromptCardResponse{
ID: row.ID,
PackageID: row.PackageID,
PackageName: row.PackageName,
PromptID: row.PromptID,
PromptName: row.PromptName,
PlatformHint: row.PlatformHint,
KolDisplayName: row.KolDisplayName,
GrantedAt: grantedAt,
})
}
return result, nil
result := make([]KolSubscriptionPromptCardResponse, 0, len(rows))
for _, row := range rows {
grantedAt := ""
if row.GrantedAt != nil {
grantedAt = row.GrantedAt.Format(time.RFC3339)
}
result = append(result, KolSubscriptionPromptCardResponse{
ID: row.ID,
PackageID: row.PackageID,
PackageName: row.PackageName,
PromptID: row.PromptID,
PromptName: row.PromptName,
PlatformHint: row.PlatformHint,
KolDisplayName: row.KolDisplayName,
GrantedAt: grantedAt,
})
}
return result, nil
})
}
func newKolMarketplacePackageResponse(pkg repository.PublishedKolPackage, viewerTenantID int64) (*KolMarketplacePackageResponse, error) {
@@ -0,0 +1,86 @@
package app
import (
"context"
"errors"
"testing"
"time"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type stubKolProfileRepository struct {
profile repository.KolProfile
}
func (r *stubKolProfileRepository) GetByUser(ctx context.Context, tenantID, userID int64) (*repository.KolProfile, error) {
if r.profile.TenantID != tenantID || r.profile.UserID != userID {
return nil, nil
}
profile := r.profile
return &profile, nil
}
func (r *stubKolProfileRepository) GetByID(ctx context.Context, id int64) (*repository.KolProfile, error) {
if r.profile.ID != id {
return nil, nil
}
profile := r.profile
return &profile, nil
}
func (r *stubKolProfileRepository) Update(ctx context.Context, tenantID int64, input repository.UpdateKolProfileInput) error {
if r.profile.TenantID != tenantID || r.profile.ID != input.ID {
return errors.New("unexpected profile update target")
}
r.profile.DisplayName = input.DisplayName
r.profile.Bio = input.Bio
r.profile.AvatarURL = input.AvatarURL
r.profile.UpdatedAt = time.Now()
return nil
}
func TestUpdateKolProfileInvalidatesPublicCaches(t *testing.T) {
t.Parallel()
ctx := context.Background()
cache := sharedcache.New("memory", nil, sharedcache.WithNamespace("test"), sharedcache.WithMetrics(false), sharedcache.WithJitterRatio(0))
tenantID := int64(42)
keys := []string{
workspaceKolCardsCacheKey(tenantID),
kolMarketplacePackageListCacheKey(tenantID, MarketFilter{Limit: 20}),
kolMarketplacePackageDetailCacheKey(tenantID, 7),
kolSubscriptionPromptListCacheKey(tenantID),
kolSubscriptionPromptSchemaCacheKey(tenantID, 9),
}
for _, key := range keys {
if err := cache.Set(ctx, key, []byte(`{"value":"stale"}`), time.Minute); err != nil {
t.Fatalf("seed cache %s: %v", key, err)
}
}
repo := &stubKolProfileRepository{profile: repository.KolProfile{
ID: 3,
TenantID: tenantID,
UserID: 5,
DisplayName: "old name",
MarketEnabled: true,
Status: "active",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}}
svc := NewKolProfileService(repo).WithCache(cache)
if _, err := svc.UpdateProfile(ctx, auth.Actor{TenantID: tenantID, UserID: 5}, UpdateKolProfileServiceInput{DisplayName: "new name"}); err != nil {
t.Fatalf("UpdateProfile() error = %v", err)
}
for _, key := range keys {
if _, err := cache.Get(ctx, key); !errors.Is(err, sharedcache.ErrNotFound) {
t.Fatalf("cache key %s should be invalidated, got err %v", key, err)
}
}
}
@@ -5,6 +5,7 @@ import (
"time"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
@@ -15,6 +16,7 @@ var ErrNotActiveKol = response.ErrForbidden(40341, "kol_not_active", "user is no
type KolProfileService struct {
repo repository.KolProfileRepository
objectStorage objectstorage.Client
cache sharedcache.Cache
}
type UpdateKolProfileServiceInput struct {
@@ -45,6 +47,11 @@ func (s *KolProfileService) WithObjectStorage(client objectstorage.Client) *KolP
return s
}
func (s *KolProfileService) WithCache(c sharedcache.Cache) *KolProfileService {
s.cache = c
return s
}
func (s *KolProfileService) RequireActiveKol(ctx context.Context, actor auth.Actor) (*repository.KolProfile, error) {
profile, err := s.repo.GetByUser(ctx, actor.TenantID, actor.UserID)
if err != nil {
@@ -91,6 +98,7 @@ func (s *KolProfileService) UpdateProfile(ctx context.Context, actor auth.Actor,
if updated == nil {
return nil, ErrNotActiveKol
}
InvalidateKolPublicCaches(ctx, s.cache)
return updated, nil
}
@@ -559,7 +559,7 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job
return
}
content := strings.TrimSpace(result.Content)
content := sanitizeGeneratedArticleMarkdown(result.Content)
if content == "" {
s.failGeneration(ctx, job, title, "llm_generate", fmt.Errorf("模型返回空内容"))
return
@@ -123,17 +123,14 @@ func questionLooksValid(text string) bool {
if utf8.RuneCountInString(trimmed) < 4 {
return false
}
hasLetterOrNumber := false
hasSearchTermChar := false
for _, r := range trimmed {
if unicode.IsLetter(r) || unicode.IsNumber(r) {
hasLetterOrNumber = true
hasSearchTermChar = true
break
}
}
if !hasLetterOrNumber {
return false
}
return containsAny(strings.ToLower(trimmed), []string{"?", "", "什么", "如何", "怎么", "哪", "为什么", "是否", "能不能", "适合", "区别", "对比", "推荐"})
return hasSearchTermChar
}
func containsAny(value string, needles []string) bool {
@@ -0,0 +1,48 @@
package app
import "testing"
func TestQuestionLooksValidAcceptsGenericSearchTerms(t *testing.T) {
tests := []string{
"全屋定制",
"GEO排名优化",
"AI搜索排名",
}
for _, text := range tests {
if !questionLooksValid(text) {
t.Fatalf("questionLooksValid(%q) = false, want true", text)
}
}
}
func TestBuildCandidateKeepsGenericSearchTermSavable(t *testing.T) {
candidate := buildCandidate(
"全屋定制",
QuestionSourceManual,
false,
map[string]struct{}{},
&questionBrandContext{BrandName: "安徽海翔家居用品销售有限公司"},
)
if candidate.TooShort {
t.Fatal("expected generic search term not to be marked too short")
}
if candidate.SuggestSkip {
t.Fatal("expected generic search term not to be suggested for skip")
}
}
func TestQuestionLooksValidRejectsEmptyOrTooShortTerms(t *testing.T) {
tests := []string{
"",
"!?。",
"AI",
}
for _, text := range tests {
if questionLooksValid(text) {
t.Fatalf("questionLooksValid(%q) = true, want false", text)
}
}
}
@@ -1222,7 +1222,7 @@ func normalizeOutlineNodes(nodes []OutlineNode, depth int) []OutlineNode {
items := make([]OutlineNode, 0, len(nodes))
for _, node := range nodes {
outline := strings.TrimSpace(node.Outline)
outline := sanitizeGeneratedOutlineText(node.Outline)
if outline == "" {
continue
}
@@ -213,7 +213,7 @@ func buildPromptContext(params map[string]interface{}) string {
return
}
switch key {
case "knowledge_group_ids", "knowledge_groups", "knowledge_context":
case "knowledge_group_ids", "knowledge_groups", "knowledge_context", "official_website", "website", "input_params":
return
}
if key == "outline_sections" && hasStructuredOutline(params["article_outline"]) {
@@ -520,7 +520,7 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
return
}
content := strings.TrimSpace(result.Content)
content := sanitizeGeneratedArticleMarkdown(result.Content)
if content == "" {
s.failGeneration(ctx, job, title, "llm_generate", fmt.Errorf("模型返回空内容"))
return
@@ -107,7 +107,6 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
"preview_caption": "预览会根据已选标题和结构实时更新。",
"sections": [
{ "key": "intro", "label": "引言", "default": true },
{ "key": "site_list", "label": "网站列表", "default": true },
{ "key": "key_points", "label": "文章关键要点", "default": true },
{ "key": "what_is_question", "label_template": "围绕{{brand_question}}展开", "default": false },
{ "key": "features", "label": "特点", "default": false },
@@ -36,7 +36,7 @@ type kolAssistStreamEvent struct {
}
func NewKolManageHandler(a *bootstrap.App, assets *AssetHandler) *KolManageHandler {
profileSvc := app.NewKolProfileService(a.KolProfiles).WithObjectStorage(a.ObjectStorage)
profileSvc := app.NewKolProfileService(a.KolProfiles).WithObjectStorage(a.ObjectStorage).WithCache(a.Cache)
return &KolManageHandler{
profileSvc: profileSvc,
@@ -18,7 +18,7 @@ type KolMarketplaceHandler struct {
func NewKolMarketplaceHandler(a *bootstrap.App) *KolMarketplaceHandler {
return &KolMarketplaceHandler{
svc: app.NewKolMarketplaceService(a.KolMarketplace, a.KolSubscriptions),
svc: app.NewKolMarketplaceService(a.KolMarketplace, a.KolSubscriptions).WithCache(a.Cache),
genSvc: app.NewKolGenerationService(
a.DB,
a.KolSubscriptions,