commit de30497f59d1cace1acbf47840afc32eb1b9db65 Author: liangxu Date: Wed Apr 1 00:58:42 2026 +0800 feat: add tenant and user management with migrations, handlers, and tests - Implemented tenant and user management features including: - Tenant creation and management with associated migrations. - User creation and management with associated migrations. - Tenant membership management with associated migrations. - Platform user roles management with associated migrations. - Quota management with associated migrations. - Article and template management with associated migrations. - Added HTTP handlers for templates and workspaces. - Created tests for protected and public routes. - Introduced a script to check tenant scope in SQL queries. - Documented task plan for backend completion and frontend foundation. diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..3e11e62 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,56 @@ +name: Backend CI + +on: + push: + paths: + - "server/**" + - ".github/workflows/backend-ci.yml" + pull_request: + paths: + - "server/**" + - ".github/workflows/backend-ci.yml" + +permissions: + contents: read + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache-dependency-path: server/go.sum + + - name: Add Go bin to PATH + run: echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Install sqlc + run: go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 + + - name: Generate sqlc code + run: make sqlc-generate + + - name: Verify generated code is committed + run: git diff --exit-code -- internal/tenant/repository/generated + + - name: Check tenant SQL scope guard + run: make tenant-scope-guard + + - name: Run tests + run: make test + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: v1.59.1 + working-directory: server + args: ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..adebf2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules/ +.pnpm-store/ +coverage/ +dist/ +.env +.env.local +apps/*/dist/ +apps/*/node_modules/ + diff --git a/apps/admin-web/.env.example b/apps/admin-web/.env.example new file mode 100644 index 0000000..8f838bb --- /dev/null +++ b/apps/admin-web/.env.example @@ -0,0 +1,3 @@ +VITE_API_BASE_URL= +VITE_API_PROXY_TARGET=http://localhost:8080 +VITE_GENERATION_STREAM_ENABLED=false diff --git a/apps/admin-web/index.html b/apps/admin-web/index.html new file mode 100644 index 0000000..48e354b --- /dev/null +++ b/apps/admin-web/index.html @@ -0,0 +1,19 @@ + + + + + + GEO 工具平台 + + +
+ + + + diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json new file mode 100644 index 0000000..024bebe --- /dev/null +++ b/apps/admin-web/package.json @@ -0,0 +1,33 @@ +{ + "name": "admin-web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "@geo/http-client": "workspace:*", + "@geo/shared-types": "workspace:*", + "@tanstack/vue-query": "^5.96.0", + "@vueuse/core": "^14.2.1", + "ant-design-vue": "^4.2.6", + "dayjs": "^1.11.20", + "pinia": "^3.0.4", + "vue": "^3.5.31", + "vue-i18n": "^10.0.5", + "vue-router": "^4.5.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^6.0.5", + "typescript": "^5.9.3", + "vite": "^8.0.3", + "vue-tsc": "^3.2.6" + } +} diff --git a/apps/admin-web/src/App.vue b/apps/admin-web/src/App.vue new file mode 100644 index 0000000..dcc99e2 --- /dev/null +++ b/apps/admin-web/src/App.vue @@ -0,0 +1,26 @@ + + + diff --git a/apps/admin-web/src/components/ArticleDetailDrawer.vue b/apps/admin-web/src/components/ArticleDetailDrawer.vue new file mode 100644 index 0000000..bbbc883 --- /dev/null +++ b/apps/admin-web/src/components/ArticleDetailDrawer.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/apps/admin-web/src/components/PageHero.vue b/apps/admin-web/src/components/PageHero.vue new file mode 100644 index 0000000..998ff9a --- /dev/null +++ b/apps/admin-web/src/components/PageHero.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/apps/admin-web/src/i18n/index.ts b/apps/admin-web/src/i18n/index.ts new file mode 100644 index 0000000..fe264f3 --- /dev/null +++ b/apps/admin-web/src/i18n/index.ts @@ -0,0 +1,44 @@ +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"; +} + +export const i18n = createI18n({ + legacy: false, + locale: resolveLocale(), + fallbackLocale: "en-US", + 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); + } +} diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts new file mode 100644 index 0000000..61f0a87 --- /dev/null +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -0,0 +1,363 @@ +const enUS = { + app: { + name: "GEO Console", + loginIntro: "A workspace for brand assets and GEO content operations.", + }, + locale: { + "zh-CN": "Simplified Chinese", + "en-US": "English", + }, + common: { + cancel: "Cancel", + close: "Close", + confirm: "Confirm", + create: "Create", + edit: "Edit", + delete: "Delete", + reset: "Reset", + refresh: "Refresh", + search: "Search", + save: "Save", + next: "Next", + previous: "Previous", + submit: "Submit", + submitGenerate: "Generate", + actions: "Actions", + title: "Title", + description: "Description", + website: "Website", + wordCount: "Words", + createdAt: "Created At", + updatedAt: "Updated At", + status: "Status", + required: "Required", + noData: "No data", + optional: "Optional", + preview: "Preview", + details: "Details", + versions: "Versions", + total: "Total", + balance: "Balance", + used: "Used", + source: "Source", + templateType: "Template", + generateStatus: "Generation", + publishStatus: "Publish", + selectPlease: "Please select", + inputPlease: "Please input", + upgrade: "Upgrade", + }, + nav: { + workspace: "Workspace", + articleCreation: "Article Creation", + templates: "Templates", + custom: "Custom Generation", + optimize: "Optimization", + media: "Media", + brandManagement: "Brand Management", + brands: "Brand Library", + tracking: "Tracking", + trackingDetail: "Data Details", + contentManagement: "Content Management", + knowledge: "Knowledge Base", + }, + auth: { + welcomeBack: "Welcome back", + tenantAdminLogin: "Tenant Admin Login", + loginAndEnter: "Sign in to workspace", + email: "Email", + password: "Password", + defaultTestAccount: "Default account", + defaultTestPassword: "Default password", + }, + shell: { + localeLabel: "Language", + quotaTitle: "Plan & Quota", + quotaStatus: "Healthy", + remainingQuota: "Article quota", + resetAt: "Reset at", + userFallback: "Admin", + logout: "Logout", + planFallback: "Free Plan", + }, + route: { + login: { + title: "Welcome back", + description: "Sign in with a real tenant account.", + }, + workspace: { + title: "Workspace", + description: "Template shortcuts, overview data, and recent generations in one place.", + }, + templates: { + title: "Template Creation", + description: "Filter existing articles, choose templates, and trigger new generation tasks.", + }, + custom: { + title: "Custom Generation", + description: "No matching backend APIs exist in this repository yet.", + }, + optimize: { + title: "Article Optimization", + description: "No matching backend APIs exist in this repository yet.", + }, + media: { + title: "Media Management", + description: "No matching backend APIs exist in this repository yet.", + }, + brands: { + title: "Brand Library", + description: "Manage keywords, question sets, and competitors around each brand.", + }, + tracking: { + title: "Data Details", + description: "No matching backend APIs exist in this repository yet.", + }, + knowledge: { + title: "Knowledge Base", + description: "No matching backend APIs exist in this repository yet.", + }, + }, + workspace: { + eyebrow: "Dashboard", + metrics: { + articles: "Generated", + published: "Published", + brands: "Brands", + platforms: "Platforms", + }, + sections: { + templates: "Article Templates", + recent: "Recent Generations", + overview: "Overview", + quota: "Plan & Quota", + }, + actions: { + enterTemplates: "Open Templates", + }, + quota: { + remaining: "Remaining quota", + total: "Total quota", + used: "Used quota", + }, + emptyTemplates: "No template cards available yet", + noRecentArticles: "No recent generations yet", + }, + templateMeta: { + top_x_article: { + eyebrow: "Ranked Content", + helper: "Good for listicles, comparisons, and recommendation-driven output.", + accent: "Top X", + action: "Create ranking", + }, + product_review: { + eyebrow: "Product Review", + helper: "Good for benefits, trade-offs, and suitability analysis.", + accent: "Review", + action: "Start review", + }, + research_report: { + eyebrow: "Research Report", + helper: "Good for longer analysis, trends, and market insight.", + accent: "Report", + action: "Create report", + }, + brand_search_expansion: { + eyebrow: "Brand Expansion", + helper: "Expand brand plus search intent into a richer GEO article.", + accent: "GEO", + action: "Expand", + }, + default: { + eyebrow: "Content Template", + helper: "This template is already usable and can go straight into generation.", + accent: "Ready", + action: "Open template", + }, + }, + status: { + generate: { + draft: "Draft", + generating: "Generating", + running: "Running", + completed: "Completed", + failed: "Failed", + }, + publish: { + unpublished: "Unpublished", + publishing: "Publishing", + published: "Published", + publish_success: "Published", + publish_failed: "Failed", + pending_review: "Pending Review", + }, + sourceType: { + template: "Template generation", + }, + }, + templates: { + eyebrow: "Article Creation", + actions: { + batchGenerate: "Batch generate", + chooseTemplate: "Choose template", + }, + filters: { + template: "Template", + publishStatus: "Publish status", + generateStatus: "Generation status", + keyword: "Search", + keywordPlaceholder: "Search by article title", + unsupportedDate: "Date range filtering is not available in the current backend yet.", + }, + list: { + count: "{count} articles", + empty: "No articles have been created yet.", + preview: "Preview", + versions: "Versions", + deleteConfirm: "Delete this article?", + deleteSuccess: "Article deleted.", + deleteError: "Failed to delete article.", + }, + picker: { + title: "Choose the template you want to use", + viewExample: "View example", + }, + wizard: { + drawerTitle: "Template generation", + steps: { + basic: "Basic info", + basicDesc: "Language, template fields, and brand context", + structure: "Structure", + structureDesc: "Title, key points, and outline", + generate: "Generate", + generateDesc: "Review and submit the task", + }, + sections: { + templateFields: "Template fields", + brandContext: "Brand context", + language: "Language", + brandInfo: "Brand", + keywords: "Keywords", + competitors: "Competitors", + chooseTitle: "Choose a title", + customTitle: "Custom title", + structure: "Article structure", + keyPoints: "Key points", + review: "Review", + notes: "Notes", + inputParams: "Input params", + }, + hints: { + brandContext: "Optional. Selecting a brand pulls in keywords and competitor context.", + competitorEmpty: "No competitors are available under this brand yet.", + customTitle: "Use this field if you want to override the title manually.", + keyPoints: "Add any key messages, angles, or style notes here.", + async: "The backend creates the article and task first, then completes content generation asynchronously.", + }, + review: { + templateName: "Template", + articleTitle: "Article title", + brandContext: "Brand context", + outline: "Outline", + currentTemplate: "Current template", + promptVisibility: "Prompt visibility", + selectedKeywords: "Selected keywords", + selectedCompetitors: "Selected competitors", + }, + localeOptions: { + zh: "Simplified Chinese", + en: "English", + }, + promptVisible: "Prompt visible", + promptSealed: "Prompt sealed", + messages: { + requiredField: "Please fill in {field} first.", + missingTitle: "Please choose or enter an article title.", + missingOutline: "Please select at least one outline section.", + submitted: "Generation task submitted. Refreshing article data.", + }, + }, + }, + article: { + drawerTitle: "Article details", + preview: "Content preview", + versionHistory: "Version history", + untitled: "Untitled article", + noContent: "This article does not have generated content yet.", + meta: { + templateType: "Template", + currentVersion: "Current version", + source: "Source label", + wordCount: "Word count", + }, + versionUntitled: "Untitled version", + }, + brands: { + eyebrow: "Brand Library", + title: "Brand Library", + description: "Manage keywords, question sets, and competitor assets around each brand.", + railTitle: "Brands", + selectBrand: "Pick a brand to continue managing keywords and question sets.", + newBrand: "New brand", + editBrand: "Edit brand", + deleteBrand: "Delete brand", + tabs: { + keywords: "Keywords", + competitors: "Competitors", + }, + sections: { + keywords: "Keywords", + questions: "Question set", + competitors: "Competitor library", + versions: "Version history", + }, + actions: { + newKeyword: "New keyword", + newQuestion: "New question", + newCompetitor: "New competitor", + viewVersions: "View versions", + }, + empty: { + brands: "No brand data yet. Create your first brand to start.", + questions: "No questions under this keyword yet.", + competitors: "No competitors under this brand yet.", + }, + form: { + brandName: "Brand name", + brandDescription: "Brand description", + keywordName: "Keyword", + questionText: "Question text", + competitorName: "Competitor name", + competitorWebsite: "Competitor website", + competitorDescription: "Competitor description", + competitorLines: "Product lines", + competitorLinesHint: "Separate multiple product lines with commas.", + }, + messages: { + createBrand: "Brand created.", + updateBrand: "Brand updated.", + deleteBrand: "Brand deleted.", + createKeyword: "Keyword created.", + updateKeyword: "Keyword updated.", + deleteKeyword: "Keyword deleted.", + createQuestion: "Question created.", + updateQuestion: "Question updated.", + deleteQuestion: "Question deleted.", + createCompetitor: "Competitor created.", + updateCompetitor: "Competitor updated.", + deleteCompetitor: "Competitor deleted.", + chooseBrand: "Please choose a brand first.", + chooseKeyword: "Please choose a keyword first.", + }, + }, + featureStub: { + eyebrow: "Feature Placeholder", + currentStatus: "Current status", + nextPriority: "Next priority", + integrationStrategy: "Integration strategy", + currentStatusText: "The page shell is ready with navigation and auth guard support.", + integrationStrategyText: "Keep the UI aligned with tenant-api envelopes and wire real read flows before write flows.", + }, +} as const; + +export default enUS; diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts new file mode 100644 index 0000000..91abbe2 --- /dev/null +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -0,0 +1,363 @@ +const zhCN = { + app: { + name: "GEO工具平台", + loginIntro: "面向企业的品牌资产管理与内容生产协作系统", + }, + locale: { + "zh-CN": "中文简体", + "en-US": "English", + }, + common: { + cancel: "取消", + close: "关闭", + confirm: "确认", + create: "新建", + edit: "编辑", + delete: "删除", + reset: "重置", + refresh: "刷新数据", + search: "搜索", + save: "保存", + next: "下一步", + previous: "上一步", + submit: "提交", + submitGenerate: "提交生成", + actions: "操作", + title: "标题", + description: "描述", + website: "官网", + wordCount: "字数", + createdAt: "创建时间", + updatedAt: "更新时间", + status: "状态", + required: "必填", + noData: "暂无数据", + optional: "可选", + preview: "预览", + details: "详情", + versions: "版本记录", + total: "总量", + balance: "剩余", + used: "已使用", + source: "来源", + templateType: "模版类型", + generateStatus: "生成状态", + publishStatus: "发布状态", + selectPlease: "请选择", + inputPlease: "请输入", + upgrade: "去升级", + }, + nav: { + workspace: "工作台", + articleCreation: "文章创作", + templates: "模版创作", + custom: "自定义生成", + optimize: "文章优化", + media: "媒体管理", + brandManagement: "品牌管理", + brands: "品牌词库", + tracking: "数据追踪", + trackingDetail: "数据详情", + contentManagement: "内容管理", + knowledge: "知识库", + }, + auth: { + welcomeBack: "欢迎回来", + tenantAdminLogin: "租户后台登录", + loginAndEnter: "登录并进入工作台", + email: "邮箱", + password: "密码", + defaultTestAccount: "默认测试账号", + defaultTestPassword: "默认测试密码", + }, + shell: { + localeLabel: "语言", + quotaTitle: "套餐与额度", + quotaStatus: "状态正常", + remainingQuota: "生成文章", + resetAt: "重置时间", + userFallback: "管理员", + logout: "退出", + planFallback: "免费版", + }, + route: { + login: { + title: "欢迎回来", + description: "使用真实租户账号登录 GEO 工具平台。", + }, + workspace: { + title: "工作台", + description: "模板入口、数据总览和最近生成都集中在这里。", + }, + templates: { + title: "模版创作", + description: "筛选已有文章、选择模版并发起新一轮内容生成。", + }, + custom: { + title: "自定义生成", + description: "当前仓库还没有匹配的后端接口,页面暂保持为设计占位。", + }, + optimize: { + title: "文章优化", + description: "当前仓库还没有匹配的后端接口,页面暂保持为设计占位。", + }, + media: { + title: "媒体管理", + description: "当前仓库还没有匹配的后端接口,页面暂保持为设计占位。", + }, + brands: { + title: "品牌词库", + description: "围绕品牌维护关键词、问题集和竞品信息。", + }, + tracking: { + title: "数据详情", + description: "当前仓库还没有匹配的后端接口,页面暂保持为设计占位。", + }, + knowledge: { + title: "知识库", + description: "当前仓库还没有匹配的后端接口,页面暂保持为设计占位。", + }, + }, + workspace: { + eyebrow: "Dashboard", + metrics: { + articles: "生成数", + published: "发布数", + brands: "品牌数", + platforms: "媒体平台数", + }, + sections: { + templates: "文章模版类型", + recent: "最近生成", + overview: "数据总览", + quota: "套餐与额度", + }, + actions: { + enterTemplates: "进入模版创作", + }, + quota: { + remaining: "剩余生成额度", + total: "总额度", + used: "已使用", + }, + emptyTemplates: "暂无模版卡片数据", + noRecentArticles: "暂无最近生成内容", + }, + templateMeta: { + top_x_article: { + eyebrow: "榜单内容", + helper: "排序推荐类文章,适合快节奏种草和结构化比较。", + accent: "Top X", + action: "创建榜单", + }, + product_review: { + eyebrow: "单品评测", + helper: "突出卖点、优缺点与适用人群,适合高转化内容。", + accent: "评测稿", + action: "发起评测", + }, + research_report: { + eyebrow: "研究报告", + helper: "更长、更专业的分析内容,适合趋势和行业洞察。", + accent: "深度稿", + action: "创建报告", + }, + brand_search_expansion: { + eyebrow: "品牌扩写", + helper: "把品牌词和搜索需求扩展成更完整的问答式内容。", + accent: "GEO", + action: "继续扩写", + }, + default: { + eyebrow: "内容模版", + helper: "当前模版已经可用,可直接进入生成流程。", + accent: "Ready", + action: "进入模版", + }, + }, + status: { + generate: { + draft: "草稿", + generating: "生成中", + running: "运行中", + completed: "已完成", + failed: "生成失败", + }, + publish: { + unpublished: "未发布", + publishing: "发布中", + published: "发布成功", + publish_success: "发布成功", + publish_failed: "发布失败", + pending_review: "待审核", + }, + sourceType: { + template: "模版单次生成", + }, + }, + templates: { + eyebrow: "Article Creation", + actions: { + batchGenerate: "批量生成文章", + chooseTemplate: "选择模版创作", + }, + filters: { + template: "模版类型", + publishStatus: "发布状态", + generateStatus: "生成状态", + keyword: "搜索文章", + keywordPlaceholder: "请输入标题内容搜索", + unsupportedDate: "当前后端尚未提供按时间范围筛选", + }, + list: { + count: "共 {count} 篇文章", + empty: "还未创建文章,暂无相关数据~", + preview: "预览正文", + versions: "查看版本", + deleteConfirm: "确认删除这篇文章吗?", + deleteSuccess: "文章已删除", + deleteError: "删除文章失败", + }, + picker: { + title: "选择你需要的模版创作", + viewExample: "查看示例", + }, + wizard: { + drawerTitle: "模版生成", + steps: { + basic: "基本信息", + basicDesc: "语言、模版字段与品牌上下文", + structure: "文章结构", + structureDesc: "标题方案、关键要点与结构段落", + generate: "生成文章", + generateDesc: "确认参数并提交生成任务", + }, + sections: { + templateFields: "模版字段", + brandContext: "品牌上下文", + language: "文章语言", + brandInfo: "品牌信息", + keywords: "关键词", + competitors: "竞品列表", + chooseTitle: "选择文章标题", + customTitle: "自定义标题", + structure: "文章结构", + keyPoints: "文章关键要点", + review: "提交前确认", + notes: "生成说明", + inputParams: "输入参数", + }, + hints: { + brandContext: "可选。选择后会自动带出关键词与竞品信息。", + competitorEmpty: "当前品牌下暂无竞品数据", + customTitle: "如需手动改写,可在这里输入最终标题", + keyPoints: "补充希望文章强调的卖点、观点或风格要求", + async: "后端会立即创建文章和任务记录,再异步完成正文生成。提交成功后,列表会自动刷新。", + }, + review: { + templateName: "模版名称", + articleTitle: "文章标题", + brandContext: "品牌上下文", + outline: "结构段落", + currentTemplate: "当前模版", + promptVisibility: "Prompt 可见性", + selectedKeywords: "已选关键词", + selectedCompetitors: "已选竞品", + }, + localeOptions: { + zh: "中文简体", + en: "English", + }, + promptVisible: "Prompt 可见", + promptSealed: "Prompt 已封装", + messages: { + requiredField: "请先填写 {field}", + missingTitle: "请先选择或输入文章标题", + missingOutline: "请至少选择一个文章结构段落", + submitted: "生成任务已提交,正在刷新文章列表。", + }, + }, + }, + article: { + drawerTitle: "文章详情", + preview: "正文预览", + versionHistory: "版本记录", + untitled: "未命名文章", + noContent: "当前文章还没有正文内容", + meta: { + templateType: "模版类型", + currentVersion: "当前版本", + source: "生成来源", + wordCount: "文章字数", + }, + versionUntitled: "未命名版本", + }, + brands: { + eyebrow: "Brand Library", + title: "品牌词库", + description: "通过品牌名称和品牌相关信息维护关键词、问题集和竞品资产。", + railTitle: "品牌库", + selectBrand: "选择品牌后即可继续维护关键词和问题集。", + newBrand: "新建品牌", + editBrand: "编辑品牌", + deleteBrand: "删除品牌", + tabs: { + keywords: "关键词", + competitors: "竞品库", + }, + sections: { + keywords: "关键词", + questions: "问题集", + competitors: "竞品库", + versions: "版本记录", + }, + actions: { + newKeyword: "新建关键词", + newQuestion: "新建问题", + newCompetitor: "新建竞品", + viewVersions: "查看版本", + }, + empty: { + brands: "还没有品牌数据,先创建一个品牌。", + questions: "当前关键词下暂无问题集。", + competitors: "当前品牌下暂无竞品数据。", + }, + form: { + brandName: "品牌名称", + brandDescription: "品牌描述", + keywordName: "关键词名称", + questionText: "问题内容", + competitorName: "竞品名称", + competitorWebsite: "竞品官网", + competitorDescription: "竞品描述", + competitorLines: "重点产品线", + competitorLinesHint: "使用英文逗号分隔多个产品线", + }, + messages: { + createBrand: "品牌已创建", + updateBrand: "品牌已更新", + deleteBrand: "品牌已删除", + createKeyword: "关键词已创建", + updateKeyword: "关键词已更新", + deleteKeyword: "关键词已删除", + createQuestion: "问题已创建", + updateQuestion: "问题已更新", + deleteQuestion: "问题已删除", + createCompetitor: "竞品已创建", + updateCompetitor: "竞品已更新", + deleteCompetitor: "竞品已删除", + chooseBrand: "请先选择品牌", + chooseKeyword: "请先选择关键词", + }, + }, + featureStub: { + eyebrow: "Feature Placeholder", + currentStatus: "当前状态", + nextPriority: "下一步重点", + integrationStrategy: "联调策略", + currentStatusText: "页面壳子已经接入主导航和鉴权守卫,后续可以直接在这里落真实交互。", + integrationStrategyText: "保持与 tenant-api 的响应结构对齐,先做真实读接口,再逐个补写接口和表单流。", + }, +} as const; + +export default zhCN; diff --git a/apps/admin-web/src/layouts/AppShell.vue b/apps/admin-web/src/layouts/AppShell.vue new file mode 100644 index 0000000..a80a6ba --- /dev/null +++ b/apps/admin-web/src/layouts/AppShell.vue @@ -0,0 +1,398 @@ + + + + + diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts new file mode 100644 index 0000000..3b7c058 --- /dev/null +++ b/apps/admin-web/src/lib/api.ts @@ -0,0 +1,327 @@ +import { createApiClient } from "@geo/http-client"; +import type { + ArticleDetail, + ArticleListParams, + ArticleListResponse, + ArticleVersion, + AuthTokens, + Brand, + BrandRequest, + Competitor, + CompetitorRequest, + JsonValue, + Keyword, + KeywordRequest, + LoginRequest, + LoginResponse, + QuotaSummary, + Question, + QuestionRequest, + QuestionVersion, + RecentArticle, + RefreshResponse, + TemplateDetail, + TemplateGenerateRequest, + TemplateGenerateResponse, + TemplateListItem, + TemplateSchema, + TemplateCard, + UpdateQuestionRequest, + UserInfo, + WorkspaceOverview, +} from "@geo/shared-types"; + +import { + clearStoredSession, + getStoredAccessToken, + getStoredRefreshToken, + setStoredTokens, +} from "./session"; + +const baseURL = import.meta.env.VITE_API_BASE_URL ?? ""; +const generationStreamEnabled = import.meta.env.VITE_GENERATION_STREAM_ENABLED === "true"; + +const publicClient = createApiClient({ baseURL }); + +export const apiClient = createApiClient({ + baseURL, + auth: { + getAccessToken: getStoredAccessToken, + getRefreshToken: getStoredRefreshToken, + setTokens: setStoredTokens, + clearTokens: clearStoredSession, + async refresh(refreshToken: string): Promise { + const data = await publicClient.post( + "/api/auth/refresh", + { refresh_token: refreshToken }, + ); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + }; + }, + }, +}); + +export const authApi = { + login(payload: LoginRequest) { + return publicClient.post("/api/auth/login", payload); + }, + me() { + return apiClient.get("/api/auth/me"); + }, + logout() { + return apiClient.post("/api/auth/logout"); + }, +}; + +export const workspaceApi = { + overview() { + return apiClient.get("/api/tenant/workspace/overview"); + }, + recentArticles() { + return apiClient.get("/api/tenant/workspace/recent-articles"); + }, + quotaSummary() { + return apiClient.get("/api/tenant/workspace/quota-summary"); + }, + templateCards() { + return apiClient.get("/api/tenant/workspace/template-cards"); + }, +}; + +export const templatesApi = { + list() { + return apiClient.get("/api/tenant/templates"); + }, + detail(id: number) { + return apiClient.get(`/api/tenant/templates/${id}`); + }, + schema(id: number) { + return apiClient.get(`/api/tenant/templates/${id}/schema`); + }, + generate(id: number, payload: TemplateGenerateRequest) { + return apiClient.post( + `/api/tenant/templates/${id}/generate`, + payload, + ); + }, +}; + +export const articlesApi = { + list(params: ArticleListParams) { + return apiClient.get("/api/tenant/articles", { params }); + }, + detail(id: number) { + return apiClient.get(`/api/tenant/articles/${id}`); + }, + versions(id: number) { + return apiClient.get(`/api/tenant/articles/${id}/versions`); + }, + remove(id: number) { + return apiClient.remove(`/api/tenant/articles/${id}`); + }, +}; + +export interface ArticleGenerationStreamEvent { + type: string; + article_id: number; + task_id?: number; + title?: string; + status?: string; + delta?: string; + content?: string; + error?: string; + done?: boolean; + updated_at?: string; +} + +export function isGenerationStreamEnabled(): boolean { + return generationStreamEnabled; +} + +export async function subscribeArticleGeneration( + articleId: number, + handlers: { + onEvent: (event: ArticleGenerationStreamEvent) => void; + onClose?: () => void; + }, + signal: AbortSignal, +): Promise { + const accessToken = getStoredAccessToken(); + if (!accessToken) { + throw new Error("missing access token"); + } + + const response = await fetch(`${baseURL}/api/tenant/articles/${articleId}/stream`, { + method: "GET", + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${accessToken}`, + }, + signal, + }); + + if (!response.ok) { + throw new Error(`stream request failed with status ${response.status}`); + } + if (!response.body) { + throw new Error("stream response body is empty"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + buffer = consumeSSEBuffer(buffer, handlers.onEvent); + } + + buffer += decoder.decode(); + consumeSSEBuffer(buffer, handlers.onEvent); + } finally { + reader.releaseLock(); + handlers.onClose?.(); + } +} + +export const brandsApi = { + list() { + return apiClient.get("/api/tenant/brands"); + }, + create(payload: BrandRequest) { + return apiClient.post("/api/tenant/brands", payload); + }, + detail(id: number) { + return apiClient.get(`/api/tenant/brands/${id}`); + }, + update(id: number, payload: BrandRequest) { + return apiClient.put(`/api/tenant/brands/${id}`, payload); + }, + remove(id: number) { + return apiClient.remove(`/api/tenant/brands/${id}`); + }, + listKeywords(brandId: number) { + return apiClient.get(`/api/tenant/brands/${brandId}/keywords`); + }, + createKeyword(brandId: number, payload: KeywordRequest) { + return apiClient.post( + `/api/tenant/brands/${brandId}/keywords`, + payload, + ); + }, + updateKeyword(brandId: number, keywordId: number, payload: KeywordRequest) { + return apiClient.put( + `/api/tenant/brands/${brandId}/keywords/${keywordId}`, + payload, + ); + }, + removeKeyword(brandId: number, keywordId: number) { + return apiClient.remove(`/api/tenant/brands/${brandId}/keywords/${keywordId}`); + }, + listQuestions(brandId: number, keywordId?: number | null) { + return apiClient.get(`/api/tenant/brands/${brandId}/questions`, { + params: keywordId ? { keyword_id: keywordId } : undefined, + }); + }, + createQuestion(brandId: number, payload: QuestionRequest) { + return apiClient.post( + `/api/tenant/brands/${brandId}/questions`, + payload, + ); + }, + updateQuestion(brandId: number, questionId: number, payload: UpdateQuestionRequest) { + return apiClient.put( + `/api/tenant/brands/${brandId}/questions/${questionId}`, + payload, + ); + }, + removeQuestion(brandId: number, questionId: number) { + return apiClient.remove(`/api/tenant/brands/${brandId}/questions/${questionId}`); + }, + listQuestionVersions(brandId: number) { + return apiClient.get(`/api/tenant/brands/${brandId}/question-versions`); + }, + listCompetitors(brandId: number) { + return apiClient.get(`/api/tenant/brands/${brandId}/competitors`); + }, + createCompetitor(brandId: number, payload: CompetitorRequest) { + return apiClient.post( + `/api/tenant/brands/${brandId}/competitors`, + payload, + ); + }, + updateCompetitor(brandId: number, competitorId: number, payload: CompetitorRequest) { + return apiClient.put( + `/api/tenant/brands/${brandId}/competitors/${competitorId}`, + payload, + ); + }, + removeCompetitor(brandId: number, competitorId: number) { + return apiClient.remove(`/api/tenant/brands/${brandId}/competitors/${competitorId}`); + }, +}; + +export function normalizeInputParams( + payload: Record, +): TemplateGenerateRequest { + return { input_params: payload }; +} + +function consumeSSEBuffer( + buffer: string, + onEvent: (event: ArticleGenerationStreamEvent) => void, +): string { + let next = buffer; + + for (;;) { + const delimiterIndex = next.indexOf("\n\n"); + if (delimiterIndex === -1) { + break; + } + + const rawEvent = next.slice(0, delimiterIndex); + next = next.slice(delimiterIndex + 2); + + const parsed = parseSSEEvent(rawEvent); + if (parsed) { + onEvent(parsed); + } + } + + return next; +} + +function parseSSEEvent(raw: string): ArticleGenerationStreamEvent | null { + const lines = raw.split(/\r?\n/); + let event = "message"; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith("event:")) { + event = line.slice(6).trim(); + continue; + } + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + } + + if (dataLines.length === 0) { + return null; + } + + const data = JSON.parse(dataLines.join("\n")) as unknown as ArticleGenerationStreamEvent; + return { + ...data, + type: event, + }; +} diff --git a/apps/admin-web/src/lib/display.ts b/apps/admin-web/src/lib/display.ts new file mode 100644 index 0000000..f9ccb2a --- /dev/null +++ b/apps/admin-web/src/lib/display.ts @@ -0,0 +1,77 @@ +import dayjs from "dayjs"; + +import { i18n } from "@/i18n"; + +const generateStatusMap: Record = { + draft: { label: "status.generate.draft", color: "default" }, + generating: { label: "status.generate.generating", color: "processing" }, + running: { label: "status.generate.running", color: "processing" }, + completed: { label: "status.generate.completed", color: "success" }, + failed: { label: "status.generate.failed", color: "error" }, +}; + +const publishStatusMap: Record = { + unpublished: { label: "status.publish.unpublished", color: "default" }, + publishing: { label: "status.publish.publishing", color: "processing" }, + published: { label: "status.publish.published", color: "success" }, + publish_success: { label: "status.publish.publish_success", color: "success" }, + publish_failed: { label: "status.publish.publish_failed", color: "error" }, + pending_review: { label: "status.publish.pending_review", color: "warning" }, +}; + +export function formatDateTime(value?: string | null, pattern = "YYYY-MM-DD HH:mm"): string { + if (!value) { + return "--"; + } + const parsed = dayjs(value); + return parsed.isValid() ? parsed.format(pattern) : value; +} + +export function getGenerateStatusMeta(status?: string | null): { label: string; color: string } { + if (!status) { + return { label: "--", color: "default" }; + } + const meta = generateStatusMap[status]; + return meta ? { label: i18n.global.t(meta.label), color: meta.color } : { label: status, color: "default" }; +} + +export function getPublishStatusMeta(status?: string | null): { label: string; color: string } { + if (!status) { + return { label: "--", color: "default" }; + } + const meta = publishStatusMap[status]; + return meta ? { label: i18n.global.t(meta.label), color: meta.color } : { label: status, color: "default" }; +} + +export function getSourceTypeLabel(sourceType?: string | null): string { + if (!sourceType) { + return "--"; + } + if (sourceType === "template") { + return i18n.global.t("status.sourceType.template"); + } + return sourceType; +} + +export function getTemplateMeta(templateKey: string): { + eyebrow: string; + helper: string; + accent: string; + action: string; +} { + const key = [ + "top_x_article", + "product_review", + "research_report", + "brand_search_expansion", + ].includes(templateKey) + ? templateKey + : "default"; + + return { + eyebrow: i18n.global.t(`templateMeta.${key}.eyebrow`), + helper: i18n.global.t(`templateMeta.${key}.helper`), + accent: i18n.global.t(`templateMeta.${key}.accent`), + action: i18n.global.t(`templateMeta.${key}.action`), + }; +} diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts new file mode 100644 index 0000000..a85ab0b --- /dev/null +++ b/apps/admin-web/src/lib/errors.ts @@ -0,0 +1,31 @@ +import { ApiClientError } from "@geo/http-client"; + +const errorMessageMap: Record = { + invalid_credentials: "邮箱或密码错误", + no_tenant: "当前账号未关联租户", + not_authenticated: "请先登录", + invalid_access_token: "登录状态已失效", + invalid_refresh_token: "刷新令牌已失效", + refresh_session_expired: "登录已过期,请重新登录", + refresh_token_mismatch: "刷新令牌校验失败,请重新登录", + token_revoked: "当前会话已经退出", + tenant_scope_missing: "租户上下文缺失", + query_failed: "数据读取失败", + quota_insufficient: "生成额度不足", + llm_unavailable: "生成服务不可用", + network_error: "网络连接失败,请稍后重试", + unknown_error: "发生未知错误", +}; + +export function formatError(error: unknown): string { + if (error instanceof ApiClientError) { + const message = errorMessageMap[error.message] ?? error.message.replaceAll("_", " "); + return error.detail ? `${message}: ${error.detail}` : message; + } + + if (error instanceof Error) { + return error.message; + } + + return "发生未知错误"; +} diff --git a/apps/admin-web/src/lib/session.ts b/apps/admin-web/src/lib/session.ts new file mode 100644 index 0000000..5fa0b7d --- /dev/null +++ b/apps/admin-web/src/lib/session.ts @@ -0,0 +1,108 @@ +import type { AuthTokens, UserInfo } from "@geo/shared-types"; + +export interface SessionSnapshot { + accessToken: string | null; + refreshToken: string | null; + user: UserInfo | null; +} + +type SessionListener = (snapshot: SessionSnapshot) => void; + +const STORAGE_KEY = "geo.admin-web.session"; +const listeners = new Set(); + +function emptySession(): SessionSnapshot { + return { + accessToken: null, + refreshToken: null, + user: null, + }; +} + +function hasStorage(): boolean { + return typeof window !== "undefined" && "localStorage" in window; +} + +function sanitizeSession(candidate: unknown): SessionSnapshot { + if (!candidate || typeof candidate !== "object") { + return emptySession(); + } + + const value = candidate as Partial; + return { + accessToken: typeof value.accessToken === "string" ? value.accessToken : null, + refreshToken: typeof value.refreshToken === "string" ? value.refreshToken : null, + user: value.user && typeof value.user === "object" ? (value.user as UserInfo) : null, + }; +} + +function notify(snapshot: SessionSnapshot): void { + listeners.forEach((listener) => listener(snapshot)); +} + +export function readStoredSession(): SessionSnapshot { + if (!hasStorage()) { + return emptySession(); + } + + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return emptySession(); + } + return sanitizeSession(JSON.parse(raw)); + } catch { + return emptySession(); + } +} + +function persistSession(snapshot: SessionSnapshot): SessionSnapshot { + if (hasStorage()) { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); + } + notify(snapshot); + return snapshot; +} + +export function updateStoredSession(partial: Partial): SessionSnapshot { + return persistSession({ + ...readStoredSession(), + ...partial, + }); +} + +export function setStoredTokens(tokens: AuthTokens): SessionSnapshot { + return updateStoredSession({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }); +} + +export function setStoredUser(user: UserInfo | null): SessionSnapshot { + return updateStoredSession({ user }); +} + +export function clearStoredSession(): SessionSnapshot { + if (hasStorage()) { + window.localStorage.removeItem(STORAGE_KEY); + } + const next = emptySession(); + notify(next); + return next; +} + +export function getStoredAccessToken(): string | null { + return readStoredSession().accessToken; +} + +export function getStoredRefreshToken(): string | null { + return readStoredSession().refreshToken; +} + +export function subscribeStoredSession(listener: SessionListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + diff --git a/apps/admin-web/src/main.ts b/apps/admin-web/src/main.ts new file mode 100644 index 0000000..69b20a5 --- /dev/null +++ b/apps/admin-web/src/main.ts @@ -0,0 +1,98 @@ +import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query"; +import { createApp } from "vue"; +import { + Alert, + App as AntApp, + Avatar, + Badge, + Button, + Card, + Checkbox, + Col, + ConfigProvider, + DatePicker, + Descriptions, + Drawer, + Dropdown, + Empty, + Form, + Input, + InputNumber, + Layout, + List, + Menu, + Modal, + Popconfirm, + Progress, + Radio, + Row, + Select, + Skeleton, + Spin, + Statistic, + Steps, + Table, + Tabs, + Tag, + Tooltip, +} from "ant-design-vue"; + +import App from "./App.vue"; +import { i18n } from "./i18n"; +import { router } from "./router"; +import { pinia } from "./stores/pinia"; +import "./styles.css"; +import "ant-design-vue/dist/reset.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30000, + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +const app = createApp(App); + +[ + Alert, + AntApp, + Avatar, + Badge, + Button, + Card, + Checkbox, + Col, + ConfigProvider, + DatePicker, + Descriptions, + Drawer, + Dropdown, + Empty, + Form, + Input, + InputNumber, + Layout, + List, + Menu, + Modal, + Popconfirm, + Progress, + Radio, + Row, + Select, + Skeleton, + Spin, + Statistic, + Steps, + Table, + Tabs, + Tag, + Tooltip, +].forEach((component) => { + app.use(component); +}); + +app.use(i18n).use(pinia).use(router).use(VueQueryPlugin, { queryClient }).mount("#app"); diff --git a/apps/admin-web/src/router/index.ts b/apps/admin-web/src/router/index.ts new file mode 100644 index 0000000..f82f34a --- /dev/null +++ b/apps/admin-web/src/router/index.ts @@ -0,0 +1,146 @@ +import { createRouter, createWebHistory } from "vue-router"; + +import { pinia } from "@/stores/pinia"; +import { useAuthStore } from "@/stores/auth"; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/login", + name: "login", + component: () => import("@/views/LoginView.vue"), + meta: { + titleKey: "route.login.title", + descriptionKey: "route.login.description", + }, + }, + { + path: "/", + component: () => import("@/layouts/AppShell.vue"), + meta: { + requiresAuth: true, + }, + children: [ + { + path: "", + redirect: "/workspace", + }, + { + path: "workspace", + name: "workspace", + component: () => import("@/views/WorkspaceView.vue"), + meta: { + titleKey: "route.workspace.title", + descriptionKey: "route.workspace.description", + navKey: "/workspace", + }, + }, + { + path: "articles/templates", + name: "articles-templates", + component: () => import("@/views/TemplatesView.vue"), + meta: { + titleKey: "route.templates.title", + descriptionKey: "route.templates.description", + navKey: "/articles/templates", + }, + }, + { + path: "articles/wizard", + name: "article-wizard", + component: () => import("@/views/TemplateWizardView.vue"), + meta: { + titleKey: "route.wizard.title", + descriptionKey: "route.wizard.description", + navKey: "/articles/templates", + }, + }, + { + path: "articles/custom", + name: "articles-custom", + component: () => import("@/views/FeatureStubView.vue"), + meta: { + titleKey: "route.custom.title", + descriptionKey: "route.custom.description", + navKey: "/articles/custom", + }, + }, + { + path: "articles/optimize", + name: "articles-optimize", + component: () => import("@/views/FeatureStubView.vue"), + meta: { + titleKey: "route.optimize.title", + descriptionKey: "route.optimize.description", + navKey: "/articles/optimize", + }, + }, + { + path: "media", + name: "media", + component: () => import("@/views/FeatureStubView.vue"), + meta: { + titleKey: "route.media.title", + descriptionKey: "route.media.description", + navKey: "/media", + }, + }, + { + path: "brands", + name: "brands", + component: () => import("@/views/BrandsView.vue"), + meta: { + titleKey: "route.brands.title", + descriptionKey: "route.brands.description", + navKey: "/brands", + }, + }, + { + path: "tracking", + name: "tracking", + component: () => import("@/views/FeatureStubView.vue"), + meta: { + titleKey: "route.tracking.title", + descriptionKey: "route.tracking.description", + navKey: "/tracking", + }, + }, + { + path: "knowledge", + name: "knowledge", + component: () => import("@/views/FeatureStubView.vue"), + meta: { + titleKey: "route.knowledge.title", + descriptionKey: "route.knowledge.description", + navKey: "/knowledge", + }, + }, + ], + }, + ], +}); + +router.beforeEach(async (to) => { + const authStore = useAuthStore(pinia); + + if (!authStore.initialized) { + await authStore.bootstrap(); + } + + const requiresAuth = to.matched.some((record) => record.meta.requiresAuth); + if (requiresAuth && !authStore.isAuthenticated) { + return { + name: "login", + query: { redirect: to.fullPath }, + }; + } + + if (to.name === "login" && authStore.isAuthenticated) { + return { name: "workspace" }; + } + + return true; +}); + +export { router }; diff --git a/apps/admin-web/src/router/meta.d.ts b/apps/admin-web/src/router/meta.d.ts new file mode 100644 index 0000000..f1f1eea --- /dev/null +++ b/apps/admin-web/src/router/meta.d.ts @@ -0,0 +1,14 @@ +import "vue-router"; + +declare module "vue-router" { + interface RouteMeta { + title?: string; + description?: string; + titleKey?: string; + descriptionKey?: string; + requiresAuth?: boolean; + navKey?: string; + } +} + +export {}; diff --git a/apps/admin-web/src/stores/auth.ts b/apps/admin-web/src/stores/auth.ts new file mode 100644 index 0000000..2f76997 --- /dev/null +++ b/apps/admin-web/src/stores/auth.ts @@ -0,0 +1,80 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; + +import type { LoginRequest, UserInfo } from "@geo/shared-types"; + +import { authApi } from "@/lib/api"; +import { + clearStoredSession, + readStoredSession, + setStoredTokens, + setStoredUser, + subscribeStoredSession, + type SessionSnapshot, +} from "@/lib/session"; + +export const useAuthStore = defineStore("auth", () => { + const accessToken = ref(null); + const refreshToken = ref(null); + const user = ref(null); + const initialized = ref(false); + let unsubscribe: (() => void) | null = null; + + function sync(snapshot: SessionSnapshot = readStoredSession()): void { + accessToken.value = snapshot.accessToken; + refreshToken.value = snapshot.refreshToken; + user.value = snapshot.user; + } + + async function bootstrap(): Promise { + if (!unsubscribe) { + unsubscribe = subscribeStoredSession(sync); + } + + sync(); + if (accessToken.value) { + try { + const currentUser = await authApi.me(); + setStoredUser(currentUser); + } catch { + clearStoredSession(); + } + } + + initialized.value = true; + } + + async function login(payload: LoginRequest): Promise { + const response = await authApi.login(payload); + setStoredTokens({ + accessToken: response.access_token, + refreshToken: response.refresh_token, + }); + setStoredUser(response.user); + return response.user; + } + + async function logout(): Promise { + try { + if (accessToken.value) { + await authApi.logout(); + } + } finally { + clearStoredSession(); + } + } + + const isAuthenticated = computed(() => Boolean(accessToken.value && user.value)); + + return { + accessToken, + refreshToken, + user, + initialized, + isAuthenticated, + bootstrap, + login, + logout, + }; +}); + diff --git a/apps/admin-web/src/stores/pinia.ts b/apps/admin-web/src/stores/pinia.ts new file mode 100644 index 0000000..6a09442 --- /dev/null +++ b/apps/admin-web/src/stores/pinia.ts @@ -0,0 +1,4 @@ +import { createPinia } from "pinia"; + +export const pinia = createPinia(); + diff --git a/apps/admin-web/src/styles.css b/apps/admin-web/src/styles.css new file mode 100644 index 0000000..280cd24 --- /dev/null +++ b/apps/admin-web/src/styles.css @@ -0,0 +1,182 @@ +/* Clean Enterprise UI Reset */ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap"); + +:root { + font-family: "Inter", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; + color: #141414; + background-color: #f0f2f5; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + --muted: #8c8c8c; + --accent: #1677ff; + --line: #f0f0f0; +} + +* { + box-sizing: border-box; +} + +html, body, #app { + min-height: 100vh; + margin: 0; + padding: 0; +} + +/* Typography Utilities */ +.eyebrow { + margin: 0 0 8px; + color: var(--muted); + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.muted { + color: var(--muted); +} + +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + +.section-heading h3 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #1a1a1a; +} + +/* Component Overrides for Ant Design to match Pro style */ +.ant-layout { + min-height: 100vh; +} + +.ant-layout-header { + box-shadow: 0 1px 4px rgba(0,21,41,0.08); /* slight shadow for topbar */ + z-index: 1; +} + +.ant-card { + box-shadow: 0 1px 2px 0 rgba(0,0,0,0.03); +} + +.ant-menu-dark .ant-menu-item-selected { + background-color: var(--accent); +} + +.app-root { + display: flex; + min-height: 100vh; +} + +/* Workspace Layout Specifics */ +.workspace-view { + display: flex; + flex-direction: column; + gap: 24px; +} + +.workspace-hero__subtitle { + color: var(--muted); + font-size: 14px; + margin-top: 8px; + margin-bottom: 24px; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px; +} + +/* Auth / Login specific styling */ +.login-screen { + min-height: 100vh; + display: flex; + flex: 1; + width: 100%; + background-color: #f0f2f5; +} + +.login-screen__cover { + flex: 1; + background: linear-gradient(135deg, #001529 0%, #1677ff 100%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #fff; + padding: 40px; + text-align: center; +} + +.login-screen__cover h1 { + font-size: 48px; + font-weight: 700; + margin-bottom: 16px; + color: #fff; +} + +.login-screen__cover p { + font-size: 18px; + opacity: 0.8; + max-width: 480px; +} + +.login-screen__form-container { + width: 500px; + background: #fff; + display: flex; + flex-direction: column; + justify-content: center; + padding: 60px 48px; + box-shadow: -4px 0 16px rgba(0,0,0,0.05); +} + +.login-header { + margin-bottom: 40px; +} + +.login-header h2 { + font-size: 28px; + font-weight: 600; + margin: 0 0 8px; + color: #141414; +} + +.login-header p { + color: var(--muted); + margin: 0; + font-size: 15px; +} + +/* Responsive */ +@media (max-width: 992px) { + .metric-grid { + grid-template-columns: repeat(2, 1fr); + } + .login-screen__cover { + display: none; + } + .login-screen__form-container { + width: 100%; + align-items: center; + } + .login-header, .ant-form { + width: 100%; + max-width: 400px; + } +} +@media (max-width: 576px) { + .metric-grid { + grid-template-columns: 1fr; + } +} diff --git a/apps/admin-web/src/views/BrandsView.vue b/apps/admin-web/src/views/BrandsView.vue new file mode 100644 index 0000000..c3ccf1a --- /dev/null +++ b/apps/admin-web/src/views/BrandsView.vue @@ -0,0 +1,937 @@ + + + + + diff --git a/apps/admin-web/src/views/FeatureStubView.vue b/apps/admin-web/src/views/FeatureStubView.vue new file mode 100644 index 0000000..37d72f4 --- /dev/null +++ b/apps/admin-web/src/views/FeatureStubView.vue @@ -0,0 +1,38 @@ + + + diff --git a/apps/admin-web/src/views/LoginView.vue b/apps/admin-web/src/views/LoginView.vue new file mode 100644 index 0000000..22850d2 --- /dev/null +++ b/apps/admin-web/src/views/LoginView.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/apps/admin-web/src/views/TemplateWizardView.vue b/apps/admin-web/src/views/TemplateWizardView.vue new file mode 100644 index 0000000..847773b --- /dev/null +++ b/apps/admin-web/src/views/TemplateWizardView.vue @@ -0,0 +1,563 @@ + + + + + diff --git a/apps/admin-web/src/views/TemplatesView.vue b/apps/admin-web/src/views/TemplatesView.vue new file mode 100644 index 0000000..18eb0c1 --- /dev/null +++ b/apps/admin-web/src/views/TemplatesView.vue @@ -0,0 +1,677 @@ + + + + + diff --git a/apps/admin-web/src/views/WorkspaceView.vue b/apps/admin-web/src/views/WorkspaceView.vue new file mode 100644 index 0000000..5f0b963 --- /dev/null +++ b/apps/admin-web/src/views/WorkspaceView.vue @@ -0,0 +1,557 @@ + + + + + diff --git a/apps/admin-web/tsconfig.json b/apps/admin-web/tsconfig.json new file mode 100644 index 0000000..9884d5a --- /dev/null +++ b/apps/admin-web/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../packages/tsconfig/base.json", + "compilerOptions": { + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@geo/shared-types": ["../../packages/shared-types/src/index.ts"], + "@geo/http-client": ["../../packages/http-client/src/index.ts"] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.vue", + "vite.config.ts" + ] +} diff --git a/apps/admin-web/vite.config.ts b/apps/admin-web/vite.config.ts new file mode 100644 index 0000000..e63f846 --- /dev/null +++ b/apps/admin-web/vite.config.ts @@ -0,0 +1,33 @@ +import vue from "@vitejs/plugin-vue"; +import { defineConfig, loadEnv } from "vite"; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, ".", ""); + + return { + plugins: [vue()], + resolve: { + alias: { + "@": new URL("./src", import.meta.url).pathname, + "@geo/shared-types": new URL( + "../../packages/shared-types/src/index.ts", + import.meta.url, + ).pathname, + "@geo/http-client": new URL( + "../../packages/http-client/src/index.ts", + import.meta.url, + ).pathname, + }, + }, + server: { + host: "0.0.0.0", + port: 5173, + proxy: { + "/api": { + target: env.VITE_API_PROXY_TARGET || "http://localhost:8080", + changeOrigin: true, + }, + }, + }, + }; +}); diff --git a/docs/Claude_architecture_review.md b/docs/Claude_architecture_review.md new file mode 100644 index 0000000..59aa0fc --- /dev/null +++ b/docs/Claude_architecture_review.md @@ -0,0 +1,348 @@ +# GEO 平台技术架构严格审查报告 + +> 基于 [geo-platform-ops-admin-tech-architecture-v1.md](file:///d:/project/geo/docs/geo-platform-ops-admin-tech-architecture-v1.md) 对照 `docs/refer` 中 8 个功能截图及两份 PRD 文档进行逐功能审查。 + +--- + +## 审查总结 + +| 评估维度 | 结果 | +| --- | --- | +| 功能覆盖度 | ⚠️ 存在多处缺口 | +| 后端模块划分 | ✅ 基本合理,部分缺失 | +| API 路由设计 | ⚠️ 多条功能链路缺少对应路由 | +| 数据模型覆盖 | ❌ 未提供数据库 Schema | +| 异步链路设计 | ⚠️ 部分 Worker 职责不完整 | +| 前端路由覆盖 | ⚠️ 缺少部分关键页面路由 | + +--- + +## 一、逐功能审查 + +### 1. 工作台 (工作台.png) + +**截图功能点:** +- 模板类型卡片入口(Top X / 产品评测 / 研究报告 / 持续扩展中) +- 右侧数据总览(生成数、发布数、品牌数、媒体平台数) +- 最近生成列表(标题、模板类型、生成状态、发布状态、字数、操作按钮) +- 左下角套餐额度区(免费版、升级入口、额度进度条、重置时间) +- 操作能力:发布、优化、预览、复制、删除 + +**架构覆盖情况:** + +| 检查项 | 状态 | 说明 | +| --- | --- | --- | +| 前端路由 `/workspace` | ✅ | 已定义 | +| API `/api/tenant/workspace/*` | ✅ | 已定义 | +| 数据总览统计接口 | ⚠️ | 未在 API 中明确统计聚合端点 | +| 套餐额度展示接口 | ⚠️ | `/api/tenant/account/*` 未明确额度查询子接口 | +| 模板类型配置管理 | ❌ | 技术架构中未定义模板类型配置的存储与下发接口 | + +> [!WARNING] +> 工作台首屏需聚合多个数据源(文章统计、品牌数、媒体数、额度),架构中未设计聚合 BFF 层或专用 Dashboard API,可能导致首屏请求过多。 + +--- + +### 2. 模板创作 (模板创作.png) + +**截图功能点:** +- 筛选区:模板类型、发布状态、生成时间、生成状态、搜索文章 +- 操作按钮:批量生成文章、选择模板创作 +- 列表:文章标题、模板类型、生成状态、发布状态、字数、操作 +- 单次生成、批量任务 + +**架构覆盖情况:** + +| 检查项 | 状态 | 说明 | +| --- | --- | --- | +| 前端路由 | ⚠️ | `admin-web` 路由中有 `/articles` 但缺少模板创作专属路由 | +| API `/api/tenant/articles/*` | ✅ | 已定义 | +| 模板定义管理 | ❌ | 后端缺少 `tenant/template` 模块定义 | +| 批量生成任务 | ✅ | `worker-generate` 已定义 | +| 模板输入字段配置 | ❌ | 未定义模板 schema 的存储与解析 | + +> [!IMPORTANT] +> 模板创作是核心链路之一,但技术架构中对"模板"概念的后端支撑严重不足。`internal/tenant` 下未见 `template` 子模块;后端模块职责 §5.5.4 仅列出 `article / brand / knowledge`,完全未提及模板管理。 + +--- + +### 3. 自定义生成 (自定义生成.png) + +**截图功能点:** +- 快捷入口:即时生成内容、定时计划生成 +- Tabs:生成文章、即时任务、定时任务、Prompt 规则 +- 筛选区:Prompt、定时任务、发布状态、标题、生成状态、生成时间 +- 列表:文章标题、规则名称、Prompt、发布平台、生成状态、发布状态、字数 +- Prompt 规则管理 + +**架构覆盖情况:** + +| 检查项 | 状态 | 说明 | +| --- | --- | --- | +| 前端路由(自定义生成) | ⚠️ | `/articles` 覆盖但未区分模板创作与自定义生成的路由 | +| Prompt 规则 CRUD | ❌ | 后端完全未定义 `tenant/prompt-rule` 模块 | +| 定时任务管理 | ❌ | 后端未定义定时调度器(Scheduler)组件 | +| 即时任务与定时任务差异化 | ❌ | `worker-generate` 仅定义"处理文章生成任务",未区分即时/定时 | +| 定时任务表 | ❌ | 数据库设计中未提及 `schedule_task` 表 | + +> [!CAUTION] +> **定时任务是自定义生成的核心子功能**,但技术架构中完全缺少: +> 1. 定时调度组件(如 cron scheduler 或 delayed message) +> 2. 定时任务 CRUD 接口 +> 3. Prompt 规则管理接口 +> +> 这是**重大架构缺失**。 + +--- + +### 4. 文章优化 (文章优化.png) + +**截图功能点:** +- 操作入口:导入已生成文章、新建文章优化、未完成优化任务 +- 筛选区:发布状态、生成时间、生成状态、搜索文章 +- 列表:作品标题/来源、字数、发布状态、最后修改时间、操作 + +**架构覆盖情况:** + +| 检查项 | 状态 | 说明 | +| --- | --- | --- | +| 前端路由 | ⚠️ | `/articles` 中未区分文章优化页 | +| 优化任务管理模块 | ❌ | 后端未定义 `tenant/optimization` 子模块 | +| 优化任务 Worker | ❌ | `worker-generate` 未声明支持优化任务类型 | +| 优化版本管理 | ❌ | 未定义 `article_version` 的存储与接口 | + +> [!WARNING] +> 文章优化涉及"导入 → 优化 → 版本保存"链路,架构中完全未涉及。PRD 中定义了 `OptimizationTask` 数据对象,但技术架构未对应出后端模块和接口。 + +--- + +### 5. 媒体管理 (媒体管理.png) + +**截图功能点:** +- 绑定流程引导(安装插件 → 登录 → 授权同步) +- 平台分类 Tabs:UGC 媒体平台、企业自建站点、第三方新闻源 +- 搜索与筛选(全部/已连接/未连接) +- 平台卡片:名称、类型、绑定状态、账号昵称、ID、运行状态 +- 操作:去绑定、发布内容 +- 支持 13+ 平台(知乎、头条号、百家号、搜狐号、企鹅号、网易号、简书、bilibili、稀土掘金、什么值得买、公众号、中关村在线等) + +**架构覆盖情况:** + +| 检查项 | 状态 | 说明 | +| --- | --- | --- | +| 前端路由 `/media` | ✅ | 已定义 | +| API `/api/tenant/media/*` | ✅ | 已定义 | +| 浏览器插件交互接口 | ⚠️ | 仅有 `/api/callback/publish/*`,缺少绑定回调接口 | +| 平台 Adapter 管理 | ✅ | `platform-api` 中有平台接入管理 | +| 平台分类模型 | ❌ | 未定义 UGC / 自建站 / 新闻源的分类架构 | +| 发布链路中的插件通信协议 | ❌ | 架构中未定义插件 ↔ 后端的通信协议和数据格式 | + +> [!WARNING] +> 媒体管理涉及浏览器插件的深度交互,但技术架构中未定义: +> - 绑定回调接口(`/api/callback/bind/*`) +> - 插件与后端的 WebSocket 或轮询通信机制 +> - 13+ 平台的 Adapter 注册与路由分发策略 + +--- + +### 6. 品牌词库 (品牌词库.png) + +**截图功能点:** +- 品牌卡片区(品牌名称、新建品牌) +- Tab:关键词、竞品库 +- 左侧关键词列表(关键词名称、数量、新建) +- 右侧问题集列表(问题内容、创建时间、操作:数据/删除) +- 新建问题集 + +**架构覆盖情况:** + +| 检查项 | 状态 | 说明 | +| --- | --- | --- | +| 前端路由 `/brands` | ✅ | 已定义 | +| API `/api/tenant/brands/*` | ✅ | 已定义 | +| 品牌-关键词-问题集层级关系 | ⚠️ | 后端模块 §5.5.4 只笼统提到"品牌资料",未展开数据层级 | +| 竞品库管理 | ❌ | 后端未定义 `competitor` 子模块 | +| 问题集"数据"操作(跳转数据详情) | ❌ | 架构中未定义品牌词→数据追踪的关联查询接口 | + +--- + +### 7. 知识库 (知识库.png) + +**截图功能点:** +- 新建内容按钮 +- 左侧分组树(默认分组、新建分组) +- 右侧列表:名称、类型、大小、AI 学习状态、上传时间、操作 + +**架构覆盖情况:** + +| 检查项 | 状态 | 说明 | +| --- | --- | --- | +| 前端路由 `/knowledge` | ✅ | 已定义 | +| API `/api/tenant/knowledge/*` | ✅ | 已定义 | +| `worker-knowledge` | ✅ | 已定义,负责解析、Embedding、Qdrant 写入 | +| 分组管理接口 | ⚠️ | 未明确分组 CRUD 的子路由 | +| 多来源类型(文档/链接/文本) | ❌ | 架构中未定义不同来源类型的解析策略差异 | +| 对象存储集成 | ✅ | 已定义用于存储原始文档 | + +> 知识库是架构覆盖度**最高**的模块。 + +--- + +### 8. 数据管理 (数据管理.png) + +**截图功能点:** +- 数据概览卡片:总发文数、总引用数、文章引用率、品牌可见率 +- 品牌排名数据区(品牌选择、关键词选择、模型选择) +- 指标 Tabs:提及率、首位提及率、首选推荐率、正面提及率 +- 趋势图(按日期折线) +- 右侧高频问题列表 +- 引用排行榜 + +**架构覆盖情况:** + +| 检查项 | 状态 | 说明 | +| --- | --- | --- | +| 前端路由 `/tracking` | ✅ | 已定义 | +| API `/api/tenant/tracking/*` | ✅ | 已定义 | +| `worker-analytics` | ✅ | 已定义,负责统计聚合 | +| 数据采集机制 | ❌ | 架构中未定义 GEO 指标(提及率、引用率等)的采集方式 | +| 品牌×关键词×模型 三维筛选 | ❌ | 后端未定义这种复合维度的查询接口设计 | +| 高频问题聚合逻辑 | ❌ | 未定义高频问题的来源和聚合策略 | +| 引用排行榜数据来源 | ❌ | 未定义引用数据的采集或模拟机制 | + +> [!CAUTION] +> 数据管理/追踪是 UI 复杂度最高的功能之一,但技术架构中几乎**仅有一行**提到 `worker-analytics` 负责"统计聚合、分析快照、缓存刷新"。缺少: +> 1. GEO 指标采集服务或爬虫组件的架构定义 +> 2. `AnalyticsSnapshot`、`MentionRecord`、`CitationRecord` 的存储方案 +> 3. 品牌×关键词×模型的多维查询 API 设计 +> 4. 高频问题的采集与聚合方案 + +--- + +## 二、跨功能架构问题 + +### 问题 1:数据库 Schema 完全缺失 + +> [!CAUTION] +> 技术架构文档 774 行,**没有任何一张数据库表定义**。PRD 中定义了 20+ 数据对象(User、Brand、Article、GenerationTask、ScheduleTask、PlatformAccount、KnowledgeItem 等),但技术架构中仅在 §7.1 笼统列出 PostgreSQL 负责的类别,未给出任何表结构、索引策略或分区方案。 + +### 问题 2:定时调度器缺失 + +技术架构定义了 4 类 Worker: +- `worker-generate` +- `worker-knowledge` +- `worker-analytics` +- `worker-publish-callback` + +但**没有定义定时调度器**。自定义生成中的"定时计划生成"功能需要 cron 调度能力,但架构中既没有独立的 `cmd/scheduler` 二进制,也没有说明使用 RabbitMQ Delayed Message 或其他延迟队列机制。 + +### 问题 3:Prompt 规则管理无后端支撑 + +自定义生成截图中有独立的 "Prompt 规则" Tab,PRD 中也明确定义了 Prompt 规则的 CRUD。但技术架构中: +- `internal/tenant` 下无 `prompt` 子模块 +- API 路由 `/api/tenant/articles/*` 中未体现规则管理 +- 平台侧 `platform/content` 提到了"Prompt 规则管理",但租户侧的 Prompt 规则管理被忽略 + +### 问题 4:浏览器插件通信架构缺失 + +媒体管理的核心链路依赖浏览器插件,但架构中: +- 未定义插件架构(虽然提到了 `apps/browser-extension`,但标记为"可选") +- 未定义绑定回调接口 +- 未定义插件 ↔ 后端的消息协议 +- 未定义发布过程中的状态同步(SSE vs WebSocket vs 轮询) + +### 问题 5:文章版本管理与优化链路缺失 + +PRD 中定义了 `ArticleVersion` 和 `OptimizationTask` 数据对象,但技术架构中: +- 未出现"版本"概念 +- 未设计优化任务的 Worker 或处理流程 +- 文章模块仅支持 CRUD,缺少版本历史能力 + +### 问题 6:SSE 实现细节不足 + +架构中多次提到 SSE(Server-Sent Events),但: +- 仅在 `internal/shared/sse` 中有一行提及 +- 未定义 SSE 连接管理、heartbeat、reconnect 策略 +- 未说明哪些场景走 SSE vs 轮询 +- 未定义 SSE 事件类型与负载格式 + +### 问题 7:前端路由粒度不足 + +`admin-web` 路由仅 9 条,但参考截图中的功能至少需要: + +```diff + /login + /workspace +-/articles +-/articles/:id ++/articles/template # 模板创作 ++/articles/template/create # 模板创建表单 ++/articles/custom # 自定义生成 ++/articles/custom/instant # 即时任务 ++/articles/custom/scheduled # 定时任务 ++/articles/custom/prompts # Prompt 规则 ++/articles/optimize # 文章优化 ++/articles/:id # 文章详情 + /brands ++/brands/:id # 品牌详情 + /knowledge + /media +-/tracking ++/tracking/overview # 数据概览 ++/tracking/detail # 数据详情 + /account +``` + +--- + +## 三、按模块严重级别排列 + +### 🔴 严重缺失(阻塞开发) + +| # | 问题 | 影响模块 | +| --- | --- | --- | +| 1 | 定时调度器完全未设计 | 自定义生成 | +| 2 | Prompt 规则管理无后端模块 | 自定义生成 | +| 3 | 模板管理后端模块缺失 | 模板创作、工作台 | +| 4 | 数据库 Schema 缺失 | 全部模块 | +| 5 | GEO 指标采集方案未定义 | 数据管理 | + +### 🟡 重要缺失(影响功能完整性) + +| # | 问题 | 影响模块 | +| --- | --- | --- | +| 6 | 文章优化/版本管理后端缺失 | 文章优化 | +| 7 | 浏览器插件通信协议未定义 | 媒体管理 | +| 8 | 绑定回调接口缺失 | 媒体管理 | +| 9 | 竞品库管理后端缺失 | 品牌词库 | +| 10 | 品牌×关键词×模型 复合查询设计缺失 | 数据管理 | + +### 🟢 轻微不足(可迭代补充) + +| # | 问题 | 影响模块 | +| --- | --- | --- | +| 11 | 前端路由粒度不足 | 全部前端 | +| 12 | SSE 实现细节不足 | 长任务反馈 | +| 13 | 知识库多来源解析策略未定义 | 知识库 | +| 14 | 工作台 Dashboard 聚合 API 未设计 | 工作台 | +| 15 | 平台分类模型未定义 | 媒体管理 | + +--- + +## 四、审查结论 + +> [!IMPORTANT] +> **总体评估:技术架构文档覆盖了系统的宏观层面(服务拆分、技术选型、部署方案),但在对照 PRD 和参考截图进行逐功能审查时,发现多处核心业务链路缺少对应的后端模块定义、API 设计和数据模型。** + +### 建议补充项(按优先级排序): + +1. **补充完整数据库 Schema 设计** — 至少覆盖 PRD 中定义的 20+ 数据对象 +2. **补充模板管理模块** — `internal/tenant/template`,含模板 CRUD、schema 解析 +3. **补充定时调度器设计** — `cmd/scheduler` 或基于 RabbitMQ Delayed Message +4. **补充 Prompt 规则管理** — `internal/tenant/prompt-rule`,含 CRUD + 关联生成 +5. **补充 GEO 数据采集架构** — 明确数据来源、采集频率、存储方案 +6. **补充文章优化模块** — `internal/tenant/optimization`,含版本管理 +7. **补充浏览器插件通信协议** — 绑定/发布回调、状态同步机制 +8. **细化前端路由表** — 按实际页面层级展开 +9. **补充 SSE 事件定义** — 事件类型、负载格式、连接管理 +10. **补充 API 接口清单** — 至少给出每个路由组下的核心端点 diff --git a/docs/claude_architecture_review_2.md b/docs/claude_architecture_review_2.md new file mode 100644 index 0000000..521def2 --- /dev/null +++ b/docs/claude_architecture_review_2.md @@ -0,0 +1,215 @@ +# GEO 平台技术架构 V1 二次审查报告 + +> 基于更新后的 [geo-platform-ops-admin-tech-architecture-v1.md](file:///d:/project/geo/docs/geo-platform-ops-admin-tech-architecture-v1.md)(1257 行),对照 `docs/refer` 中 8 个功能截图、两份 PRD 及 5 万并发用户目标进行审查。 + +--- + +## 一、前次缺失修复确认 + +| # | 前次严重缺失 | 修复状态 | 说明 | +| --- | --- | --- | --- | +| 1 | 定时调度器完全未设计 | ✅ 已修复 | §13.7.2 新增 `cmd/scheduler`,数据库到期扫描 + 行级锁防重 | +| 2 | Prompt 规则管理无后端模块 | ✅ 已修复 | §13.5.3 新增 `tenant/prompt-rule`,§13.11.2 新增 `prompt_rules` 表 | +| 3 | 模板管理后端模块缺失 | ✅ 已修复 | §13.5.3 新增 `tenant/template`,§13.11.2 新增 `article_templates` 表 | +| 4 | 数据库 Schema 缺失 | ✅ 已修复 | §13.11 提供 30+ 表的最小 Schema,含关键字段和索引 | +| 5 | GEO 指标采集方案未定义 | ✅ 已修复 | §13.9 定义完整的问题驱动监控模型 | +| 6 | 文章优化/版本管理缺失 | ✅ 已修复 | 新增 `optimization_tasks` + `article_versions` 表 | +| 7 | 浏览器插件通信协议缺失 | ✅ 已修复 | §13.8 定义插件会话模型 + 回调协议 | +| 8 | 绑定回调接口缺失 | ✅ 已修复 | §13.6.3 新增 `/api/callback/plugin/bind` | +| 9 | 竞品库管理缺失 | ✅ 已修复 | §13.11.3 新增 `competitors` 表 | +| 10 | 复合维度查询缺失 | ✅ 已修复 | §13.9 + §13.11.6 定义 `keyword_model_daily_metrics` 多维聚合 | +| 11 | 前端路由粒度不足 | ✅ 已修复 | §13.4 展开了完整路由 | +| 12 | SSE 细节不足 | ✅ 已修复 | §13.10 定义事件类型、负载格式、连接策略 | + +> [!TIP] +> **前次审查中指出的 15 项问题已全部修复。** 文档从 774 行扩展到 1257 行,新增的 §13 闭环补齐方案质量很高。 + +--- + +## 二、功能完整性复查(对照 docs/refer 截图) + +| 功能页 | 路由 | API | 数据模型 | 异步组件 | 评价 | +| --- | --- | --- | --- | --- | --- | +| 工作台 | ✅ | ✅ 聚合 API | ✅ | ✅ | **完备** | +| 模板创作 | ✅ | ✅ | ✅ `article_templates` | ✅ `worker-generate` | **完备** | +| 自定义生成 | ✅ | ✅ | ✅ `prompt_rules` `schedule_tasks` | ✅ `scheduler` + `worker-generate` | **完备** | +| 文章优化 | ✅ | ✅ | ✅ `optimization_tasks` `article_versions` | ✅ `worker-generate` | **完备** | +| 媒体管理 | ✅ | ✅ + 回调 | ✅ `plugin_sessions` `publish_records` | ✅ 插件 + `worker-publish-callback` | **完备** | +| 品牌词库 | ✅ | ✅ | ✅ 含问题版本 + 竞品 | ✅ `worker-analytics` | **完备** | +| 知识库 | ✅ | ✅ | ✅ 分组 + 多来源 | ✅ `worker-knowledge` | **完备** | +| 数据追踪 | ✅ | ✅ | ✅ 5 张聚合表 | ✅ `scheduler` + `worker-analytics` | **完备** | + +> **结论:8 个功能页全部达到"页面 → 路由 → API → 对象 → 任务 → 存储 → 状态回传"的完整闭环。** + +--- + +## 三、面向 5 万并发用户的技术问题 + +以下是基于"5 万人同时使用 + 足够简单"目标的严格审查: + +### 🔴 必须解决的问题 + +#### 1. Gateway 单点瓶颈 — 缺少水平扩展方案 + +文档中 `cmd/gateway` 是唯一入口,但未说明: +- Gateway 是否无状态可水平扩展(多实例部署) +- 前端连接的负载均衡策略(L4/L7) +- Gateway 与 `tenant-api` / `platform-api` 之间的通信方式(直连 or 服务发现) + +> 5 万并发用户假设平均每人每 10 秒发一次请求 = **5000 QPS**。单个 Go 进程可以处理,但 SSE 长连接会占用大量连接数。 + +**建议**:明确 Gateway 无状态 + Kubernetes HPA 策略 + Nginx/Ingress L7 负载均衡。 + +#### 2. SSE 连接数问题 — 5 万长连接的资源消耗 + +§13.10 设计了 SSE 推送,但 5 万用户全部保持 SSE 长连接意味着: +- **5 万个 TCP 连接同时保活** +- 每个连接 15s heartbeat = **3333 条/秒** 仅心跳 +- 内存占用:每连接约 10-50KB → 约 **500MB - 2.5GB** 仅连接缓冲 + +**建议**: +- V1 默认使用 **轮询**(10s 间隔),仅在执行长任务时临时升级为 SSE +- 或者只在活跃操作页面(文章生成中、发布中)才建立 SSE,非活跃页面走轮询 +- 明确 SSE 连接上限和优雅关闭策略 + +#### 3. Scheduler 单实例风险 + +§13.7.2 描述 Scheduler 使用数据库行级锁防重复派发,但: +- 单 Scheduler 实例是单点故障 +- 行级锁在高并发下有锁竞争风险 + +**建议**: +- V1 可接受单实例 + 进程级健康检查自动重启 +- 但需要明确:如果 Scheduler 宕机 5 分钟,到期任务是否会堆积?重启后是否能自动追回? +- 文档中应补充 `Scheduler 故障恢复策略` + +#### 4. PostgreSQL 单库承压 + +30+ 张表全部在一个 PostgreSQL 实例中,5 万用户场景下: +- `articles` 表增长最快(每用户每天可能生成 5-20 篇) +- `question_monitor_runs` 按 `问题数 × 模型数 × 天数` 增长 +- `audit_logs` 写入量大,查询范围广 + +**建议**: +- 审计日志表按月分区(`PARTITION BY RANGE (created_at)`) +- 监控数据表按日期分区 +- `articles` 添加 `tenant_id` 分区索引或按 tenant 分片策略 +- 明确 V1 阶段的数据量估算和归档策略 + +#### 5. 缺少限流与熔断设计 + +5 万用户中如果某个租户突发大量请求(如批量生成 1000 篇文章),可能打爆队列。文档只在 §7.2 提到 Redis 限流计数,但缺乏系统性设计: + +**建议**: +- 在 Gateway 层增加租户级限流(令牌桶/滑动窗口) +- 在 Worker 层增加模型调用限频(保护下游 LLM API) +- 在 MQ 层设置队列容量上限 + 拒绝策略 +- 生成任务提交时校验排队深度,超阈值直接拒绝并提示 + +--- + +### 🟡 建议优化的问题 + +#### 6. 对象存储缺少 CDN 加速策略 + +知识库的原始文档和图片素材通过对象存储管理(§7.5),但 5 万用户同时访问文章预览中的图片时: +- 需要 CDN 分发(阿里云 OSS 天然支持,MinIO 需手动配 Nginx/CDN) +- 建议文档中明确:线上版走 OSS CDN,私有化部署走 Nginx 反代 MinIO + +#### 7. Redis 缓存雪崩风险 + +§13.9.2 提到 Redis 缓存 30 天摘要,但未设计: +- 缓存预热策略 +- 缓存过期时间错开(避免集体失效) +- 缓存穿透保护(布隆过滤器或空值缓存) + +**建议**:关键缓存(工作台统计、趋势图)使用 `TTL + 异步刷新` 模式,避免大量请求击穿到 PostgreSQL。 + +#### 8. `worker-generate` 职责过重 + +§13.5.2 中 `worker-generate` 同时承载:模板生成、Prompt 生成、批量生成、文章优化。这意味着: +- 一个 Worker 进程要处理 4 种不同的任务类型 +- 批量生成可能堵塞优化任务 + +**建议**: +- 队列内使用 `routing key` 或 `priority` 区分任务类型 +- 或拆分为 `q.generate.interactive`(即时任务,高优先级)和 `q.generate.batch`(批量/定时任务,低优先级) +- V1 在代码层面保持一个 Worker 二进制,但消费端可部署多实例分配不同 routing key + +#### 9. Sponge 框架兼容性未评估 + +§2.2 提到使用 [sponge](https://github.com/go-dev-frame/sponge) 框架,但文档中的分层架构(transport → app → domain → repository)与 sponge 的代码生成模式是否兼容未说明: +- sponge 生成的代码结构是否支持 `internal/tenant` + `internal/platform` 的双域拆分? +- sponge 的 handler/service/dao 分层与文档中的 transport/app/domain/repository 如何映射? + +**建议**:明确 sponge 的使用范围(仅脚手架?还是深度依赖代码生成?),避免框架约束与架构设计冲突。 + +我的意见:优先架构设计为先,sponge 只是为了快速生成代码,但是一定架构设计为先 + +#### 10. 缺少全局配置管理方案 + +文档提到 `internal/shared/config`,但未说明: +- 配置从哪里加载(文件?环境变量?配置中心?) +- 运行时配置变更是否支持热加载 +- 多个服务(gateway / tenant-api / platform-api / scheduler / workers)的配置如何统一管理 + +**建议**:V1 使用 `YAML 配置文件 + 环境变量覆盖`,明确配置层级。 + +是用动态配置加载技术 + +--- + +### 🟢 文档层面的小问题 + +#### 11. §5.5.9 `platform/audit` 内容空缺 + +第 406-407 行 `platform/audit` 只有标题没有内容,而 §13 中才补齐。应删除或填充。 + +#### 12. §5.6.3 两服务协作原则有"悬挂列表" + +第 436-439 行的 4 个日志类型列表与上文语义断裂,像是从 `platform/audit` 模块复制过来的遗留。 + +#### 13. `viewer` 角色在 §6.2.2 被删除 + +原版有 `viewer`(只读成员),更新版替换为 `kol`。但 PRD 中明确定义了"只读成员"角色,建议保留 `viewer` 并额外增加 `kol`。 + +#### 14. Mermaid 图中缺少 `scheduler` 节点 + +§8 交互图(第 553-599 行)未包含 `scheduler`,虽然 §13.5.1 补了修订图,但原图应同步更新或标注以修订图为准。 + +#### 15. 后端结构图(§9)缺少 `scheduler` 节点 + +第 603-691 行的 Mermaid 图中 cmd 子图也缺少 scheduler 二进制。 + +--- + +## 四、面向"足够简单"目标的评估 + +| 维度 | 评价 | 风险点 | +| --- | --- | --- | +| 服务数量 | ✅ **合理** — 8 个进程 | 足够收敛,V1 可控 | +| 中间件数量 | ⚠️ **偏多** — PG + Redis + RabbitMQ + Qdrant + OSS | 但都是必需的,无冗余 | +| 数据库表 | ⚠️ **30+ 张** | 但每张表职责单一,不算过度 | +| 框架选型 | ⚠️ Sponge 框架 | 需确认不会引入额外复杂度 | +| 部署复杂度 | ✅ Docker Compose 可覆盖本地开发 | 生产环境需 K8s | +| 前端应用数 | ✅ 2 + 1 插件 | 合理 | + +> [!IMPORTANT] +> **总体评价:架构已从"宏观方案"升级为"可开发闭环方案"。功能覆盖度 100%,数据模型完整。主要残留问题集中在 5 万并发的性能架构层面(SSE 连接管理、数据库分区、限流熔断)和文档一致性上。** + +--- + +## 五、修改优先级建议 + +| 优先级 | 修改项 | 工作量 | +| --- | --- | --- | +| P0 | 补充 Gateway 水平扩展和负载均衡方案 | 0.5h | +| P0 | 修订 SSE 策略:默认轮询、长任务页面才开 SSE | 0.5h | +| P0 | 补充租户级限流 + 队列容量保护设计 | 1h | +| P1 | 补充 PostgreSQL 分区策略(审计/监控表) | 0.5h | +| P1 | 恢复 `viewer` 角色 + 增加 `kol` | 5min | +| P1 | 评估 sponge 框架与双域分层的兼容性 | 1h | +| P2 | 统一旧图与新图(§8/§9 补 scheduler) | 15min | +| P2 | 修复 §5.5.9 空内容 + §5.6.3 悬挂列表 | 10min | +| P2 | 补充配置管理方案 | 0.5h | +| P2 | 补充对象存储 CDN 方案 | 15min | diff --git a/docs/claude_architecture_review_3.md b/docs/claude_architecture_review_3.md new file mode 100644 index 0000000..56eeac6 --- /dev/null +++ b/docs/claude_architecture_review_3.md @@ -0,0 +1,318 @@ +# GEO 平台技术架构深度审查报告(第三轮) + +> 在前两轮审查(功能覆盖度 + 5 万并发初审)基础上,交叉审查全部 6 份文档(共 ~4700 行),聚焦**跨文档一致性、数据流完整性、实施风险**三个维度。 + +--- + +## 一、跨文档一致性审查 + +现有文档体系: + +| 文档 | 行数 | 定位 | +| --- | --- | --- | +| [geo-platform-ops-admin-tech-architecture-v1.md](file:///d:/project/geo/docs/geo-platform-ops-admin-tech-architecture-v1.md) | 1257 | 🏗️ 主架构 | +| [geo-platform-prd-v1.md](file:///d:/project/geo/docs/geo-platform-prd-v1.md) | 1079 | 📋 租户端 PRD | +| [geo-platform-ops-admin-prd-v1.md](file:///d:/project/geo/docs/geo-platform-ops-admin-prd-v1.md) | 652 | 📋 平台管理端 PRD | +| [media-binding-and-publish-v1.md](file:///d:/project/geo/docs/media-binding-and-publish-v1.md) | 562 | 🔌 插件方案 | +| [qdrant-rag-architecture-v1.md](file:///d:/project/geo/docs/qdrant-rag-architecture-v1.md) | 651 | 🧠 RAG 方案 | +| [question-driven-monitoring-design-v1.md](file:///d:/project/geo/docs/question-driven-monitoring-design-v1.md) | 495 | 📊 数据监控方案 | + +### 问题 1:RAG 组件未纳入主架构图和交互图 + +[qdrant-rag-architecture-v1.md](file:///d:/project/geo/docs/qdrant-rag-architecture-v1.md) 定义了完整的检索链路:`query rewrite → filter → Qdrant 检索 → rerank → 上下文拼装 → 提交模型`。但主架构中: + +- §8 交互图中 `worker-generate → Qdrant` 的箭头缺失(只有 `worker-knowledge → QD`) +- 文章生成链路(§10.1)只提到"调用模型",**没有 RAG 检索步骤** +- 后端模块职责中无 `shared/rag` 或 `shared/retrieval` 模块 + +> [!WARNING] +> **影响**:`worker-generate` 生成文章时需要从 Qdrant 检索品牌知识,这条数据流在主架构中完全不可见。开发时容易遗漏 RAG 集成。 +> +> **建议**: +> 1. 交互图中为 `worker-generate → Qdrant` 增加箭头 +> 2. §10.1 生成链路在步骤 3 和 4 之间插入 "RAG 检索品牌知识上下文" +> 3. 后端目录中增加 `internal/shared/retrieval` 模块 + +### 问题 2:media-binding 插件协议与主架构定义存在分歧 + +| 对比项 | media-binding-v1.md | 主架构 §13.8 | +| --- | --- | --- | +| 绑定落库 | 前端调用后端 API 保存 | 插件**双通道**:回传前端 + 回调 API 落库 | +| 会话管理 | 无 `plugin_session` 概念 | 有 `plugin_sessions` 表 + `session_token` | +| 回调路径 | `POST /api/platform-accounts/bind` | `POST /api/callback/plugin/bind` | +| 发布记录 | `POST /api/publish-records` | `POST /api/callback/plugin/publish` | + +> **结论**:主架构 §13.8 的设计更完善(双通道 + 会话令牌 + 审计),但 media-binding-v1.md 还是旧方案。**两份文档需要统一**,否则前端、插件、后端开发人员会看不同的接口文档。 + +### 问题 3:数据追踪的数据模型字段不一致 + +| 对比项 | question-driven-monitoring-v1.md | 主架构 §13.11.6 | +| --- | --- | --- | +| 汇总表名 | `question_model_daily_metrics` | 未出现此表(但有 `keyword_model_daily_metrics`) | +| `question_monitor_runs` 字段 | 有 `run_at`、`task_batch_id` | 缺少 `run_at`、`task_batch_id` | +| `question_monitor_parse_results` 字段 | 有 `citation_platform_count`、`parser_version` | 缺少这两个字段 | +| 保留策略 | 明确 30/90/180 天分级 | 未提及保留策略 | + +> **建议**:主架构 §13.11.6 的 Schema 应与 [question-driven-monitoring-design-v1.md](file:///d:/project/geo/docs/question-driven-monitoring-design-v1.md) 完全对齐,特别是补充 `question_model_daily_metrics` 表和缺失字段。 + +### 问题 4:RAG 中的 `knowledge_chunks_meta` 表未出现在主架构 Schema + +[qdrant-rag-architecture-v1.md](file:///d:/project/geo/docs/qdrant-rag-architecture-v1.md) §6.1 提到 PostgreSQL 需保存 `knowledge_chunks_meta`(chunk 元数据和状态),但主架构 §13.11.4 知识库 Schema 中**没有这张表**。这意味着: + +- 每个文档被切成多少 chunk、每个 chunk 的 token 数、status 在 PostgreSQL 中无处存储 +- chunk 重建/版本化时没有元数据支持 + +> **建议**:在 §13.11.4 增加 `knowledge_chunks_meta` 表: +> ``` +> | knowledge_chunks_meta | id, knowledge_item_id, chunk_index, token_count, qdrant_point_id, item_version, status | +> ``` + +### 问题 5:RAG 中的 `retrieval_logs` 和 `rag_eval_cases` 未纳入主架构 + +RAG 文档要求记录每次检索的 query/filter/rerank 结果用于 badcase 分析,但主架构没有对应表和模块。V1 如果不记录,后续无法评估 RAG 精准度。 + +> **建议**:如果目标是"足够简单",V1 可以先将检索日志写入**文件日志**而非数据库表,后续再升级。在架构中明确这一策略。 + +### 问题 6:PRD 中定义了 `kol` 角色但无实际权限描述 + +主架构 §6.2.2 新增 `kol`(用于某个专家的 prompt 从底层共享),但: +- 租户端 PRD 只定义了 管理员/编辑/只读 三种角色 +- `kol` 角色的具体权限范围(能看什么、能做什么)未在任何文档中定义 +- PRD 中的 `viewer`(只读成员)角色被删除 + +> **建议**:保留 `viewer`、额外增加 `kol`,并在权限矩阵中补充 `kol` 的能力边界。 + +### 问题 7:`article_templates` 表的 `tenant_id` 字段暗示模板是租户级的 + +主架构 §13.11.2 中 `article_templates` 有 `tenant_id`。但: +- 平台端 PRD §8.5 明确有"系统模板管理"功能 +- 平台级模板应 `tenant_id = NULL` 或专门的 `scope` 字段 +- 参考截图中工作台的模板卡片(Top X / 产品评测 / 研究报告)看起来是**平台级**预设 + +> **建议**:`article_templates` 表增加 `scope` 字段(`platform` / `tenant`),平台级模板 `tenant_id = NULL`,租户可在此基础上自定义。 + +### 问题 8:ops-admin-web PRD 中的"平台接入管理"路由在主架构 §13.4.2 中已有但 §5.3.3 中缺失 + +§13.4.2 ops-admin-web 路由有 `/platform/integrations/*`,§13.5.4 模块也有 `platform/integration`,但 §5.3.3 原始 API 路由列表中没有 `/api/platform/integrations/*`。 + +> **影响较小**,因为 §13.6.2 覆盖了。但前后不一致容易造成混乱。 + +--- + +## 二、关键数据流深度审查(面向 5 万并发 + 简单) + +### 数据流 1:文章生成全链路 + +``` +用户 → admin-web → gateway → tenant-api(额度校验 + 任务入队) +→ RabbitMQ → worker-generate(RAG 检索 + 模型调用 + 结果存储) +→ PostgreSQL + Redis → SSE/轮询 → 前端更新 +``` + +**关键问题:LLM 调用超时与成本** + +- 一篇文章生成可能调用 LLM 3-20 秒(取决于文章长度和模型) +- 5 万用户中假设 1% 同时在生成 = **500 并发生成任务** +- 每个任务占用 1 个 Worker goroutine + 1 个 LLM API 连接 +- 如果 LLM 上游限频(如 DeepSeek 300 RPM),Worker 会排队堆积 + +> [!CAUTION] +> **架构中缺少**: +> 1. LLM 调用超时策略(多久算超时?超时后任务状态如何?) +> 2. LLM 上游限流适配(令牌桶/重试退避) +> 3. 生成任务的优先级控制(VIP 租户优先?) +> 4. Worker 对 LLM 的并发连接池设计 + +### 数据流 2:知识库入库全链路 + +``` +用户上传 → tenant-api → 对象存储 → RabbitMQ +→ worker-knowledge(提取 → 清洗 → 切片 → Embedding → Qdrant 写入 + PG 元数据) +``` + +**关键问题:Embedding 调用成本** + +- 每个文档可能切出 50-200 个 chunk +- 每个 chunk 需要一次 Embedding API 调用 +- 50 万租户 × 平均 10 个知识文档 = 500 万文档 → 潜在 2.5-10 亿 chunk + +> 但 V1 阶段用户量有限,这主要是演进方向的考量。当前建议在架构中明确 **Embedding 批量调用**策略(一次请求多个 chunk),减少 API round-trip。 + +### 数据流 3:定时任务调度全链路 + +``` +scheduler 每分钟扫描 → schedule_tasks(到期的) → 生成 generation_tasks → 入队 RabbitMQ + → 生成 analytics 任务 → 入队 RabbitMQ +``` + +**关键问题:调度雷群效应** + +- 很多租户的定时任务可能设在同一时刻(如每天 08:00) +- 某分钟可能扫描出数千个到期任务 +- 全部同时入队可能瞬间打爆 `q.generate` 队列 + +> **建议**: +> - Scheduler 支持 **平滑派发**(将同时到期的任务分散到 5-10 分钟窗口内入队) +> - 或租户创建定时任务时自动 **加随机偏移量**(±15 分钟) + +### 数据流 4:数据追踪采集全链路 + +``` +scheduler → 为每个启用的 question_version × model 生成采集任务 → RabbitMQ +→ worker-analytics(调用模型 → 解析回答 → 存解析结果) +→ 日级聚合 → 刷新缓存 +``` + +**关键问题:采集任务与生成任务共享模型调用** + +- 数据追踪的采集本质上也是"调用 LLM 获取回答" +- 与文章生成共用 LLM 额度/限频 +- 但采集任务优先级应**低于**用户直接触发的生成任务 + +> **建议**: +> - 采集任务使用独立的 LLM 通道/频次限制 +> - 或在共享通道中标记优先级,用户直接任务优先 + +### 数据流 5:插件发布全链路 + +``` +admin-web → 创建 plugin_session → browser-extension 执行发布 +→ 同时:回传前端(即时反馈)+ 回调 /api/callback/plugin/publish(可靠落库) +→ tenant-api 存储 publish_records → SSE 广播 +``` + +**问题:插件离线时的降级处理** + +- 如果用户没装插件、或插件版本过低,点击"发布"会发生什么? +- 主架构中未定义**插件检测**机制(前端如何知道插件是否可用?) +- media-binding-v1.md §12 提到只注入本系统后台域名,但没定义版本检测协议 + +> **建议**:前端在页面加载时检测插件安装状态和版本,未安装或版本过低时灰掉发布按钮并引导安装。在 `packages/shared-types` 中定义最低兼容插件版本常量。 + +--- + +## 三、实施风险与工程注意事项 + +### 风险 1:`worker-generate` 中 LLM 超时导致 goroutine 泄漏 + +Go 中如果 HTTP 请求到 LLM API 设置了很长的超时(如 60s),大量排队任务会占用大量 goroutine。如果 LLM 侧故障,可能导致 goroutine 数量爆炸。 + +> **建议**: +> - 对 LLM 调用设置 `context.WithTimeout`(建议 30s) +> - Worker 设置最大并发 goroutine 数(如基于 `semaphore` 限制并发 50) +> - 超时任务标记为 `failed`,进入重试队列 + +### 风险 2:PostgreSQL 事务边界不清晰 + +生成任务涉及多表写入:`articles` + `article_versions` + `generation_tasks` + `tenant_quota_ledgers`。如果不在同一事务中: +- 额度扣了但文章没写入 → 额度泄漏 +- 文章写了但额度没扣 → 超额使用 + +> **建议**:文档中明确关键事务边界——至少"额度扣减 + 任务创建"必须在同一 PostgreSQL 事务中。Worker 侧的"结果写入 + 额度确认"也应事务保护。 + +### 风险 3:migration 策略缺失 + +30+ 张表的 Schema 定义好了,但没有 migration 策略: +- 如何管理 Schema 版本?(golang-migrate / Atlas / sponge 内置?) +- 线上 DDL 变更如何无停机执行? +- 是否有 down migration? + +> **建议**:明确使用 `golang-migrate` 或 `Atlas`,并在 `server/migrations/` 目录约定命名规则。 + +### 风险 4:`tenant_id` 数据隔离的执行保障 + +5 万用户意味着大量租户。所有租户数据共库共表,`tenant_id` 过滤是唯一隔离手段。如果某个查询漏加 `tenant_id`,就是数据泄露。 + +> **建议**: +> - 在 repository 层封装 `TenantScope(ctx)` 自动注入 `WHERE tenant_id = ?` +> - 在 CI 中运行静态分析检查所有查询是否带 `tenant_id` +> - 文档中将此列为**强制编码规范** + +### 风险 5:对象存储的清理策略 + +知识库文档、原始模型回答、发布请求/响应 payload 持续写入对象存储。无清理策略意味着存储成本线性增长。 + +> **建议**: +> - 已删除知识文档的对象存储文件应在 T+7 天后清理 +> - 原始模型回答按 `question-driven-monitoring` 建议保留 30-90 天 +> - 定期运行 GC 任务扫描"孤儿文件" + +### 风险 6:Sponge 框架的 code-gen 与手写代码冲突 + +Sponge 的核心卖点是代码生成(从 protobuf/sql 生成 handler/service/dao)。但主架构的分层是自定义的 `transport/app/domain/repository`。如果使用 sponge 的代码生成: +- 生成的代码放在哪个层? +- 手写的 domain 逻辑如何与生成的 dao 共存? +- 重新生成时是否覆盖手写代码? + +> **建议**:明确 sponge 仅用于**脚手架初始化**(项目初始化、数据库连接、中间件配置),**不使用**其默认的 handler/service/dao 代码生成,由团队按自定义分层手写业务代码。 + +### 风险 7:SSE 与 Kubernetes HPA 的兼容性 + +Kubernetes 默认负载均衡(基于 Service → Pod 的 round-robin)会将新请求分配到不同 Pod。但 SSE 是长连接,如果用户的 SSE 连接在 Pod A,但任务结果写入后通知只发到 Pod B,用户收不到。 + +> **建议**: +> - SSE 推送使用 **Redis Pub/Sub** 作为跨 Pod 广播层 +> - 或使用 **Sticky Session**(Ingress `nginx.ingress.kubernetes.io/affinity: cookie`) +> - 文档中需要明确选择哪种方案 + +### 风险 8:`brand_question_versions` 的碎片化 + +每次用户修改问题文案就新增一个版本。如果用户频繁微调(改错字、加时间等),版本表会快速碎片化。监控计划如果绑定 `question_version_id`,可能产生大量只使用 1-2 天的版本。 + +> **建议**: +> - 对问题修改增加 **debounce**(如 10 分钟内多次修改只产生一个版本) +> - 或者只在"发布/启用"时才生成新版本,编辑中间态不算版本 + +### 风险 9:批量生成的额度预扣与回滚 + +用户提交"批量生成 100 篇"时: +- 是预扣 100 次额度? +- 还是每篇生成成功时扣 1 次? +- 如果预扣后有 30 篇生成失败,如何退回额度? + +> **建议**:文档应明确**额度策略**:推荐"提交时预扣 + 失败后按条退还 + 定期对账"模式。 + +### 风险 10:`viewer` 角色缺失导致 PRD 不满足 + +PRD 明确定义了"只读成员"角色,但主架构删除了 `viewer` 替换为 `kol`。如果按当前架构开发: +- "只读成员"无法创建——PRD 交付不通过 +- 前端权限配置缺少对应的角色枚举 + +--- + +## 四、"足够简单"维度的优化建议 + +| 可简化选项 | 当前方案 | 简化建议 | 风险 | +| --- | --- | --- | --- | +| SSE 推送 | 全场景 SSE | **V1 全部用轮询**(10s),V1.5 引入 SSE | 延迟略高但架构极度简化 | +| Gateway 独立进程 | `cmd/gateway` | **V1 把网关逻辑合并到 tenant-api 的中间件**,省一个进程 | 后续拆分需重构 | +| Rerank 模块 | cross-encoder reranker | **V1 跳过 rerank**,只用 dense + filter + top5 | RAG 精度降低 | +| 独立 Scheduler | `cmd/scheduler` | **用 cron job 或 pg_cron** 代替独立进程 | 灵活性降低 | +| `question_model_daily_metrics` | 问题级汇总表 | **V1 只保留 `keyword_model_daily_metrics`** | 丢失问题级穿透能力 | + +> [!NOTE] +> 以上是**可选简化**,不是必须。如果团队人力充足,保持当前设计无问题。但如果"足够简单"是硬约束,可以选择部分简化。 + +--- + +## 五、修改建议汇总 + +### P0(必须修复) + +| # | 问题 | 预计工作量 | +| --- | --- | --- | +| 1 | 主架构中补充 RAG 检索步骤(交互图 + 生成链路 + 模块定义) | 30min | +| 2 | 统一 media-binding-v1.md 与主架构 §13.8 的接口定义 | 1h | +| 3 | 补充 LLM 调用超时/限流/并发池设计 | 30min | +| 4 | 明确关键事务边界(额度扣减 + 任务创建 + 结果写入) | 15min | +| 5 | 恢复 `viewer` 角色 + 定义 `kol` 角色权限 | 15min | + +### P1(重要补充) + +| # | 问题 | 预计工作量 | +| --- | --- | --- | +| 6 | §13.11.4 补充 `knowledge_chunks_meta` 表 | 10min | +| 7 | §13.11.6 与 question-driven-monitoring 对齐字段 | 20min | +| 8 | `article_templates` 增加 `scope` 字段区分平台/租户模板 | 5min | +| 9 | 补充 SSE 跨 Pod 广播方案(Redis Pub/Sub 或 Sticky Session) | 20min | +| 10 | 补充额度预扣/退还/对账策略 | 15min | diff --git a/docs/claude_architecture_review_4.md b/docs/claude_architecture_review_4.md new file mode 100644 index 0000000..6957ffc --- /dev/null +++ b/docs/claude_architecture_review_4.md @@ -0,0 +1,242 @@ +# GEO 平台技术架构 V1 综合审查报告(第四轮) + +> 以大厂技术架构师视角,综合前三轮 Claude 审查结论,对 [geo-platform-ops-admin-tech-architecture-v1.md](file:///d:/project/geo/docs/geo-platform-ops-admin-tech-architecture-v1.md) 进行终审,聚焦 **可实施性** 和 **运维简单性**。 + +--- + +## 一、前三轮审查问题收敛确认 + +### 第一轮 → 第二轮(功能闭环修复) + +| # | 问题 | 状态 | +| --- | --- | --- | +| 1 | 定时调度器缺失 | ✅ 已补齐 `cmd/scheduler` | +| 2 | Prompt 规则管理缺失 | ✅ 已补齐模块 + 表 | +| 3 | 模板管理缺失 | ✅ 已补齐 `article_templates` | +| 4 | 数据库 Schema 缺失 | ✅ 已补齐 30+ 张表 | +| 5 | GEO 指标采集方案缺失 | ✅ 已补齐问题驱动监控 | +| 6-12 | 版本管理 / 插件协议 / SSE / 路由 | ✅ 全部修复 | + +### 第二轮 → 第三轮(5 万并发 + 一致性修复) + +| # | 问题 | 状态 | +| --- | --- | --- | +| 1 | Gateway 扩展方案 | ✅ §13.13.1 补充了 HPA + L7 负载均衡 | +| 2 | SSE 连接策略 | ✅ §13.10.3 改为默认轮询 + 按需 SSE | +| 3 | Scheduler 故障恢复 | ✅ §13.13.2 补充了恢复策略 | +| 4 | PostgreSQL 分区 | ✅ §13.13.3 补充了审计/监控表分区 | +| 5 | 限流 + 熔断 | ✅ §13.13.4 补充了租户级令牌桶 + Worker 并发池 | +| 6 | 配置管理方案 | ✅ §13.13.6 补充了三级配置 | +| 7 | Sponge 使用原则 | ✅ §13.13.7 明确仅作脚手架 | +| 8 | Migration 策略 | ✅ §13.13.8 补充了 golang-migrate | +| 9 | 租户隔离保障 | ✅ §13.13.9 补充了 TenantScope | + +### 第三轮提出的残留问题 + +| # | 问题 | 当前状态 | +| --- | --- | --- | +| 1 | RAG 检索未纳入主架构图 | ✅ §13.5.1 修订图已补 `WG → QD` 箭头;§5.5.2 新增 `shared/retrieval` | +| 2 | media-binding 接口不一致 | ⚠️ **仍残留** — media-binding-v1.md 未同步更新 | +| 3 | 数据追踪 Schema 字段不一致 | ✅ §13.11.6 已与监控方案对齐 | +| 4 | `knowledge_chunks_meta` 表缺失 | ✅ §13.11.4 已补充 | +| 5 | `retrieval_logs` 落地策略 | ✅ §13.11.4 末尾明确 V1 写文件日志 | +| 6 | `kol` 角色权限不清 | ✅ §6.2.3 补充了权限矩阵 | +| 7 | `article_templates.scope` | ✅ §13.11.2 已补充 scope 字段 | +| 8 | LLM 超时 / 限流 / 并发池 | ✅ §13.13.4 明确 30s 超时 + semaphore 50 | +| 9 | 事务边界 | ✅ §13.13.9 明确了关键事务边界 | +| 10 | 额度预扣/退还策略 | ✅ §13.7.3 明确了预扣 + 失败退还 + 对账 | + +> [!TIP] +> **三轮审查共计约 40 项问题,当前仅剩 1 项残留(media-binding 文档未同步)。架构整体完成度非常高。** + +--- + +## 二、当前架构总体评价 + +### 2.1 架构复杂度评级 + +| 维度 | 现状 | 评价 | +| --- | --- | --- | +| 进程数量 | gateway + 2 API + scheduler + 4 worker = **8 个** | ⚠️ 偏多但合理 | +| 中间件 | PG + Redis + RabbitMQ + Qdrant + OSS = **5 个** | ⚠️ 每个都必要,无冗余 | +| 数据库表 | **30+ 张** | ✅ 职责单一,不算过度 | +| 前端应用 | 2 后台 + 1 插件 | ✅ 合理 | +| 部署形态 | Docker Compose(开发)+ K8s(生产) | ✅ 标准 | + +### 2.2 可实施性判断 + +> [!IMPORTANT] +> **结论:可实施。** 文档已覆盖从页面路由 → API → 数据模型 → 异步链路 → 部署的完整闭环。8 个功能页全部具备"可开发"条件。 + +**但有以下前提条件:** +1. 团队至少需要 2 名 Go 后端 + 1 名前端 + 1 名 DevOps +2. LLM/Embedding 供应商的限频和成本需提前确认 +3. 首版上线建议先控制在 **1000 并发** 内验证,而非直接冲 5 万 + +--- + +## 三、本轮发现的问题与建议 + +以下是前三轮未充分覆盖、但作为架构师我认为必须关注的问题: + +### 🔴 必须修正(影响运维简单性或可实施性) + +#### 问题 1:V1 阶段 Gateway 存在过度设计的嫌疑 + +当前方案 `cmd/gateway` 作为独立进程做路由分发。但 V1 只有 2 个下游(tenant-api、platform-api),且 gateway 只做鉴权 + 路由转发。 + +**运维影响**:多一个进程 = 多一套部署/监控/日志/重启策略。 + +**建议**: +- **V1 简化方案**:将 gateway 的鉴权中间件合并到 `tenant-api` 和 `platform-api` 中,由 Nginx/Ingress 按 `/api/tenant/*` 和 `/api/platform/*` 做 L7 路由分发 +- 省掉 1 个进程,运维直接减负 +- V1.5 如果需要统一限流/审计注入,再独立出 gateway + +> [!WARNING] +> 如果选择保留 gateway,则必须确保它"足够薄"——只做路由 + 鉴权 + 限流,**绝不放业务逻辑**。否则它会变成系统的"上帝对象"。 + +我的想法是保留的,但第一版,只做路由 + 鉴权 + 限流 + +#### 问题 2:8 个进程的运维负担 + +生产环境需要管理 8 个独立二进制(gateway + tenant-api + platform-api + scheduler + 4 worker),每个都需要: +- 独立的 Dockerfile +- 独立的 K8s Deployment + Service +- 独立的日志采集 +- 独立的健康检查 + +**建议**: +- 4 个 Worker 在 V1 阶段考虑合并为 **2 个 Worker 二进制**: + - `worker-main`:消费 generate + knowledge + analytics 队列(按 routing key 区分) + - `worker-callback`:消费 publish-callback 队列 +- 代码层面保持模块隔离(`internal/jobs/` 下仍然分独立 package),只是 `cmd/` 入口合并 +- 部署从 8 个进程降为 **5 个**(tenant-api + platform-api + scheduler + worker-main + worker-callback) + +审核员想法: 不采纳,合并后系统用户体验会变差会更慢 + +#### 问题 3:`viewer` 角色仍未恢复 + +§6.2.2 中只有 `tenant_admin` / `editor` / `kol`。PRD 明确定义了"只读成员"。 + +审核员意图:viewer 角色没用,删除;kol其实也只是用户,但是这个角色比普通用户要更会写 prompt,别人可以直接使用她的分享码,使用她分享的prompt(其中主要信息,比如公司名啊,地址啊,还是得用户填) + +--- + +### 🟡 建议优化(提升运维效率) + +#### 问题 4:缺少统一的错误码规范 + +文档定义了 40+ 个 API 接口,但没有统一的错误码体系。前后端联调时会出现: +- 后端返回 `{"error": "xxx"}` 格式不统一 +- 前端无法区分"额度不足"和"LLM 超时"和"参数错误" + +**建议**:定义统一错误响应和错误码段: + +``` +{ + "code": 40301, + "message": "quota_insufficient", + "detail": "生成额度不足,请升级套餐" +} +``` + +| 错误码段 | 含义 | +| --- | --- | +| 400xx | 参数校验 | +| 401xx | 认证 | +| 403xx | 权限/额度 | +| 404xx | 资源不存在 | +| 409xx | 状态冲突 | +| 500xx | 内部错误 | +| 503xx | 下游不可用(LLM/Qdrant) | + +#### 问题 5:缺少日志规范与链路追踪 + +8 个进程的分布式架构,单个请求可能跨 gateway → API → MQ → Worker。排查问题需要: +- 统一的 `request_id` / `trace_id` 贯穿全链路 +- 结构化日志(JSON 格式) +- 日志级别规范 + +`internal/shared/observability` 模块已定义在目录结构中,但文档中没有展开。 + +**建议**: +- 所有进程使用统一日志库(如 `zap`) +- gateway 生成 `request_id`,全链路透传 +- MQ 消息 header 中携带 `trace_id` +- 使用 Jaeger + +#### 问题 6:Docker Compose 本地开发的启动顺序和依赖 + +5 个中间件 + 8 个服务的本地开发环境,`docker-compose up` 时: +- PG 未就绪时 API 连接失败 +- RabbitMQ 未初始化队列时 Worker 报错 +- Qdrant 启动慢导致知识库功能不可用 + +**建议**: +- `docker-compose.yml` 中只放中间件(PG/Redis/RabbitMQ/Qdrant/MinIO) +- Go 服务本地 `go run` 开发,用 `Makefile` 或 `Task` 统一启动 +- 提供一键初始化脚本:`make dev-init`(创建队列、运行 migration、创建 bucket) + +#### 问题 7:media-binding-v1.md 仍与主架构不一致 + +第三轮审查已指出,至今未同步。 + +**建议**:以主架构 §13.8 的插件会话模型为准,更新 media-binding-v1.md 的接口定义。 + +--- + +### 🟢 小问题(不阻塞但建议修正) + +| # | 问题 | 说明 | +| --- | --- | --- | +| 1 | §8 旧交互图与 §13.5.1 修订图并存 | 建议删除旧图或标注"以 §13 为准" | +| 2 | §9 后端结构图缺 scheduler 节点 | 与实际设计不符 | +| 3 | §5.5.9 `platform/audit` 只有标题无内容 | 已在 §13 补齐,建议填充或删除旧节 | +| 4 | Vite 8 版本号存疑 | §2.2 提到 `Vite 8`,截至 2026.3 Vite 最新稳定版需确认 | + +--- + +## 四、运维简单性终审 + +### 4.1 运维维度打分 + +| 维度 | 评分 | 说明 | +| --- | --- | --- | +| 部署复杂度 | ⭐⭐⭐ | 8 个进程偏多,建议合并 Worker | +| 监控难度 | ⭐⭐⭐ | 有 observability 模块但未展开 | +| 故障排查 | ⭐⭐ | 缺少 trace_id + 结构化日志规范 | +| 扩容简易度 | ⭐⭐⭐⭐ | gateway/API 无状态,HPA 就绪 | +| 配置管理 | ⭐⭐⭐⭐ | 三级配置合理 | +| 数据库运维 | ⭐⭐⭐ | 分区策略已定义,缺归档自动化 | + +### 4.2 V1 运维简化建议清单 + +| # | 简化措施 | 效果 | +| --- | --- | --- | +| 1 | 合并 4 Worker 为 2 个 | 减少 2 个部署单元 | +| 2 | 合并 Gateway 到 Ingress 路由 | 减少 1 个部署单元 | +| 3 | 统一日志 + request_id | 大幅降低排查成本 | +| 4 | 提供 `make dev-init` | 新人 10 分钟启动开发环境 | +| 5 | 自动化 migration CI | 避免手动 DDL 事故 | +| 6 | 审计/监控表自动归档脚本 | 避免磁盘满 | + +--- + +## 五、最终结论 + +> [!IMPORTANT] +> ### 可实施性:✅ 通过 +> +> 文档已从"宏观方案"演进为可直接进入开发的"闭环方案"。8 个功能页全部具备路由 → API → 模型 → 异步 → 存储 → 状态回传的完整链路。 +> +> ### 运维简单性:⚠️ 有优化空间 +> +> 当前 **8 个进程 + 5 个中间件** 对小团队偏重。建议 V1 合并 Worker、考虑去掉独立 Gateway,将运维实体控制在 **5 个进程** 以内。 + +### 如果只改三件事: + +1. **合并 Worker**:4 个 Worker 二进制合并为 2 个(-2 部署单元) +2. **统一日志与 trace**:所有进程使用 `zap` + `request_id` 贯穿全链路 +3. **恢复 `viewer` 角色**:PRD 强制要求,不能漏 + +其余问题(错误码、旧图清理、media-binding 同步等)可在开发过程中逐步完善。 diff --git a/docs/geo-platform-ops-admin-prd-v1.md b/docs/geo-platform-ops-admin-prd-v1.md new file mode 100644 index 0000000..57c1e30 --- /dev/null +++ b/docs/geo-platform-ops-admin-prd-v1.md @@ -0,0 +1,651 @@ +# GEO 平台管理员端 PRD V1 + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档名称 | GEO 平台管理员端 PRD V1 | +| 文档版本 | V1.0 | +| 文档状态 | 待评审 | +| 创建日期 | 2026-03-25 | +| 适用阶段 | V1 立项 / 设计 / 开发评审 | +| 关联文档 | `docs/geo-platform-prd-v1.md` | +| 关联文档 | `docs/geo-platform-tech-architecture-v1-50k.md` | +| 关联文档 | `docs/media-binding-and-publish-v1.md` | + +### 1.1 文档目标 + +本文档用于明确 GEO 平台管理员端 V1 的产品定位、权限边界、信息架构、核心流程、页面需求与验收标准,作为产品、设计、前后端、测试共同评审和执行的依据。 + +### 1.2 版本说明 + +- V1 聚焦搭建平台级运营控制台,支撑租户管理、套餐额度管理、模型配置、任务运维、监控告警和审计追踪。 +- V1 不追求完整财务结算、复杂工单审批流、自动化修复编排和企业级 CMDB 能力。 + +## 2. 项目背景 + +### 2.1 背景说明 + +当前 `docs/geo-platform-prd-v1.md` 主要描述的是租户端业务后台,服务对象是品牌运营、内容编辑和只读成员。随着平台能力扩大,仅有租户端后台无法满足平台内部运营与管理需要,主要会出现以下问题: + +- 无法统一查看全租户状态、套餐、额度和到期情况。 +- 模型配置、Prompt 规则、系统模板等平台能力缺少集中管理入口。 +- 异步任务失败、队列积压、平台发布异常时缺少统一运维面板。 +- 高风险操作缺少集中审计,后续无法追踪谁修改了什么。 +- 平台客服、运营、财务、审核等内部角色与租户端角色边界混乱。 + +因此需要建设独立的“平台管理员端”,作为平台内部运营、客服、超管和审核团队的统一控制台。 + +### 2.2 目标用户 + +- 超级管理员 +- 平台运营 +- 客服支持 +- 财务运营 +- 审核 / 风控人员 +- 技术运维人员 + +### 2.3 核心价值 + +- 统一管理租户生命周期、套餐和额度策略。 +- 统一管理平台级模型能力、模板能力和内容规则。 +- 统一查看任务状态、系统健康和异常告警。 +- 统一记录高风险操作、配置变更和额度变更。 +- 明确平台内部角色权限边界,避免与租户端混用。 + +## 3. 产品定位与边界 + +### 3.1 产品定位 + +平台管理员端是 GEO 平台的内部运营控制台,不面向租户开放。其核心目标不是“使用内容生产能力”,而是“管理平台、治理风险、保障稳定、支撑运营”。 + +### 3.2 与租户端的关系 + +| 维度 | 租户端后台 | 平台管理员端 | +| --- | --- | --- | +| 服务对象 | 品牌方、编辑、只读成员 | 平台内部团队 | +| 数据范围 | 当前租户 | 全平台或指定租户 | +| 核心目标 | 生成内容、发布内容、查看效果 | 管租户、管配置、管任务、管风险 | +| 权限模型 | `tenant_role` | `platform_role` | +| 风险等级 | 中 | 高 | +| 推荐前端应用 | `admin-web` | `ops-admin-web` | + +### 3.3 成功指标 + +- 新租户开通与初始化平均耗时 <= 10 分钟 +- 平台人员查询租户状态与额度信息平均耗时 <= 1 分钟 +- 异步任务失败定位时间 <= 5 分钟 +- 平台关键配置变更具备 100% 审计记录 +- 高风险操作具备二次确认和操作留痕 + +### 3.4 V1 范围 + +V1 包含: + +- 平台工作台 +- 租户中心 +- 平台用户与角色管理 +- 套餐与额度管理 +- 模型中心 +- 内容配置中心 +- 平台接入管理 +- 任务运维中心 +- 监控告警 +- 审计中心 + +V1 不包含: + +- 完整支付结算系统 +- 多级审批流引擎 +- 自动化故障修复编排 +- 外部合作方管理门户 +- 自助报表搭建平台 + +## 4. 用户角色与权限 + +### 4.1 平台角色定义 + +#### 4.1.1 超级管理员 + +- 管理全部平台配置和权限 +- 查看全量租户、任务、监控和审计数据 +- 执行高风险操作,如冻结租户、修改模型默认策略、恢复关键任务 + +#### 4.1.2 平台运营 + +- 管理租户、套餐、额度和平台模板 +- 查看任务状态和平台基础监控 +- 不负责财务口径变更与底层密钥管理 + +#### 4.1.3 客服支持 + +- 查询租户信息、任务状态、发布失败原因 +- 协助排查问题并查看操作日志 +- 不允许修改关键配置和发放额度 + +#### 4.1.4 财务运营 + +- 管理套餐档位、额度策略和补量记录 +- 查看租户套餐使用情况和到期信息 +- 不允许修改模型参数和发布规则 + +#### 4.1.5 审核 / 风控 + +- 管理敏感词、拦截策略和审核规则 +- 查看异常发布记录和风险操作日志 +- 不允许修改租户套餐和模型渠道密钥 + +#### 4.1.6 技术运维 + +- 查看服务健康、队列积压、Worker 状态和告警记录 +- 支持任务重试、恢复和故障排查 +- 不直接参与租户商务配置 + +### 4.2 权限原则 + +- 平台管理员端与租户端账号体系逻辑隔离,但可复用统一认证服务。 +- 后端权限控制必须基于 `platform_role + action + resource_scope` 校验,不能仅依赖前端隐藏菜单。 +- 所有高风险操作必须具备二次确认、操作原因和审计日志。 +- 涉及租户数据的查看与修改,需要明确记录目标 `tenant_id`。 +- 密钥、令牌、模型渠道凭证等敏感数据默认脱敏显示。 + +### 4.3 权限矩阵 + +| 模块 | 超级管理员 | 平台运营 | 客服支持 | 财务运营 | 审核 / 风控 | 技术运维 | +| --- | --- | --- | --- | --- | --- | --- | +| 租户查看 | 是 | 是 | 是 | 是 | 否 | 是 | +| 租户冻结 / 解冻 | 是 | 是 | 否 | 否 | 否 | 否 | +| 套餐与额度调整 | 是 | 是 | 否 | 是 | 否 | 否 | +| 模型参数配置 | 是 | 否 | 否 | 否 | 否 | 是 | +| 模板 / Prompt 配置 | 是 | 是 | 否 | 否 | 是 | 否 | +| 敏感词 / 审核规则 | 是 | 否 | 否 | 否 | 是 | 否 | +| 任务重试 / 恢复 | 是 | 是 | 否 | 否 | 否 | 是 | +| 监控告警查看 | 是 | 是 | 是 | 否 | 是 | 是 | +| 审计日志查看 | 是 | 是 | 是 | 是 | 是 | 是 | + +## 5. 信息架构 + +### 5.1 一级导航 + +- 工作台 +- 租户中心 +- 平台用户 +- 模型中心 +- 内容配置 +- 平台接入 +- 任务运维 +- 监控告警 +- 审计中心 + +### 5.2 二级导航 + +#### 5.2.1 工作台 + +- 平台概览 + +#### 5.2.2 租户中心 + +- 租户列表 +- 租户详情 +- 套餐与额度 +- 冻结与启用记录 + +#### 5.2.3 平台用户 + +- 用户列表 +- 角色权限 +- 登录审计 + +#### 5.2.4 模型中心 + +- LLM 配置 +- Embedding 配置 +- 渠道密钥 +- 限流策略 + +#### 5.2.5 内容配置 + +- 系统模板 +- Prompt 规则 +- 分类标签 +- 敏感词规则 + +#### 5.2.6 平台接入 + +- 媒体平台 +- Adapter 配置 +- 发布策略 +- 平台状态 + +#### 5.2.7 任务运维 + +- 生成任务 +- 知识学习任务 +- 发布回调任务 +- 重试任务 +- 死信任务 + +#### 5.2.8 监控告警 + +- 服务健康 +- 队列监控 +- Worker 监控 +- 存储监控 +- 告警记录 + +#### 5.2.9 审计中心 + +- 操作日志 +- 配置变更 +- 额度变更 +- 风险操作记录 + +### 5.3 路由建议 + +```text +/platform/dashboard +/platform/tenants +/platform/tenants/:id +/platform/plans +/platform/platform-users +/platform/platform-roles +/platform/login-audits +/platform/models/llm +/platform/models/embedding +/platform/models/channels +/platform/models/limits +/platform/content/templates +/platform/content/prompts +/platform/content/tags +/platform/content/sensitive-rules +/platform/integrations/media-platforms +/platform/integrations/adapters +/platform/integrations/publish-policies +/platform/tasks/generation +/platform/tasks/knowledge +/platform/tasks/publish +/platform/tasks/retry +/platform/tasks/dead-letter +/platform/monitoring/services +/platform/monitoring/queues +/platform/monitoring/workers +/platform/monitoring/storage +/platform/monitoring/alerts +/platform/audit/operations +/platform/audit/config-changes +/platform/audit/quota-changes +/platform/audit/risk-actions +``` + +## 6. 核心流程 + +### 6.1 租户开通流程 + +1. 平台人员创建租户。 +2. 系统初始化租户基础信息、默认套餐与额度。 +3. 平台人员可选配置默认模型策略与模板能力。 +4. 系统生成初始租户管理员账号或邀请入口。 +5. 审计中心记录租户创建与初始化操作。 + +### 6.2 套餐与额度调整流程 + +1. 平台人员进入租户详情或套餐与额度页面。 +2. 选择调整配额、重置时间或补量类型。 +3. 系统要求填写变更原因并二次确认。 +4. 变更结果立即生效或按设定时间生效。 +5. 系统写入额度变更日志并通知相关租户。 + +### 6.3 模型配置发布流程 + +1. 平台人员修改默认模型、限流策略或渠道参数。 +2. 系统校验参数完整性、作用范围和冲突风险。 +3. 高风险配置变更需要二次确认。 +4. 配置生效后,后续新任务按最新策略执行。 +5. 系统写入配置变更审计记录。 + +### 6.4 失败任务处理流程 + +1. 平台人员在任务运维页筛选失败任务。 +2. 查看任务参数、错误日志、重试次数、租户信息。 +3. 根据错误类型选择重试、终止或转人工排查。 +4. 系统记录操作人、操作原因与执行结果。 +5. 若任务恢复成功,相关页面状态同步更新。 + +### 6.5 风险操作审计流程 + +1. 任意高风险操作发起前展示确认弹窗。 +2. 操作人必须填写原因或选择标准原因模板。 +3. 系统写入审计日志,记录前后变更快照。 +4. 审计中心支持按人员、租户、模块、时间范围回溯查询。 + +## 7. 功能清单与优先级 + +| 模块 | 功能点 | 优先级 | +| --- | --- | --- | +| 工作台 | 平台核心指标、异常概览、快捷入口 | P0 | +| 租户中心 | 租户列表、详情、冻结、启用、搜索筛选 | P0 | +| 套餐与额度 | 套餐档位、额度调整、补量记录、到期管理 | P0 | +| 平台用户 | 平台账号、角色分配、登录审计 | P0 | +| 模型中心 | LLM 配置、Embedding 配置、限流、渠道管理 | P0 | +| 内容配置 | 系统模板、Prompt 规则、敏感词规则 | P1 | +| 平台接入 | 平台清单、Adapter 状态、发布策略 | P1 | +| 任务运维 | 任务列表、失败重试、死信处理 | P0 | +| 监控告警 | 服务健康、队列积压、Worker 监控、告警记录 | P0 | +| 审计中心 | 操作日志、配置变更、额度变更、风险操作记录 | P0 | + +## 8. 页面详细需求 + +## 8.1 工作台 + +### 8.1.1 页面目标 + +为平台内部人员提供全平台运行概况、异常风险提示和高频操作入口。 + +### 8.1.2 页面结构 + +- 核心指标卡 +- 风险告警区 +- 队列与任务概览 +- 快捷操作区 +- 最近关键操作时间线 + +### 8.1.3 功能说明 + +展示指标建议包括: + +- 总租户数 +- 活跃租户数 +- 今日任务提交数 +- 今日失败任务数 +- 当前队列积压数 +- 当前告警数 + +快捷入口建议包括: + +- 新建租户 +- 调整额度 +- 查看失败任务 +- 查看告警详情 + +### 8.1.4 验收标准 + +- 首屏可展示全平台概览指标。 +- 异常信息支持直接跳转到对应详情页。 +- 指标数据刷新机制明确,避免人工刷新才能看到最新状态。 + +## 8.2 租户中心 + +### 8.2.1 页面目标 + +统一管理租户生命周期、套餐状态、基础资料和使用情况。 + +### 8.2.2 页面结构 + +- 租户列表页 +- 租户详情页 +- 套餐与额度页 +- 冻结与启用记录页 + +### 8.2.3 功能说明 + +租户列表字段建议包括: + +- 租户名称 +- 租户状态 +- 套餐类型 +- 已用 / 总额度 +- 到期时间 +- 创建时间 +- 最近活跃时间 + +租户详情建议包括: + +- 基本信息 +- 账号与成员概览 +- 品牌数 / 文章数 / 发布数 +- 最近任务状态 +- 最近错误与风险提示 +- 操作日志时间线 + +### 8.2.4 状态规则 + +租户状态: + +- trial +- active +- frozen +- expired + +### 8.2.5 验收标准 + +- 可按租户名、状态、套餐、时间筛选。 +- 冻结和解冻操作具备二次确认。 +- 套餐与额度调整具备留痕。 + +## 8.3 平台用户 + +### 8.3.1 页面目标 + +统一管理平台内部账号、角色与登录行为。 + +### 8.3.2 功能说明 + +- 创建平台账号 +- 分配平台角色 +- 禁用 / 启用账号 +- 查看最近登录记录 +- 查看异常登录提醒 + +### 8.3.3 验收标准 + +- 角色变更后权限立即生效。 +- 登录审计可按用户和时间范围查询。 +- 禁用账号后不可继续访问平台管理员端。 + +## 8.4 模型中心 + +### 8.4.1 页面目标 + +统一管理模型供应商、渠道、默认策略和限流能力。 + +### 8.4.2 功能说明 + +- LLM 模型配置 +- Embedding 模型配置 +- 渠道密钥管理 +- 默认模型策略 +- 按租户或套餐设置限流 + +### 8.4.3 风险控制 + +- 密钥默认脱敏 +- 高风险变更要求二次确认 +- 支持记录配置前后差异 + +### 8.4.4 验收标准 + +- 可配置默认模型与备用模型。 +- 限流策略可按租户或套餐生效。 +- 所有变更都能在审计中心回溯。 + +## 8.5 内容配置 + +### 8.5.1 页面目标 + +统一管理平台级模板、Prompt 规则和内容安全策略。 + +### 8.5.2 功能说明 + +- 系统模板管理 +- Prompt 规则管理 +- 分类标签管理 +- 敏感词与拦截规则管理 + +### 8.5.3 验收标准 + +- 模板和规则可启用、停用、版本化管理。 +- 敏感词规则对生成和发布链路可生效。 +- 变更具备审计记录。 + +## 8.6 平台接入 + +### 8.6.1 页面目标 + +统一管理平台支持的媒体平台、适配器状态和发布策略。 + +### 8.6.2 功能说明 + +- 平台清单管理 +- Adapter 状态与版本信息 +- 发布限制规则 +- 平台可用性状态展示 + +### 8.6.3 验收标准 + +- 可查看每个平台当前接入状态。 +- 当平台异常时可快速标记并通知平台运营。 +- 发布策略调整具备审计留痕。 + +## 8.7 任务运维 + +### 8.7.1 页面目标 + +统一查看各类异步任务的状态,并支持重试、终止和排查。 + +### 8.7.2 任务范围 + +- 文章生成任务 +- 知识学习任务 +- 发布回调任务 +- 统计聚合任务 +- 死信任务 + +### 8.7.3 功能说明 + +- 按任务类型、状态、租户、时间筛选 +- 查看错误信息、耗时、重试次数 +- 支持任务重试和终止 +- 支持查看上下游关联记录 + +### 8.7.4 状态规则 + +任务状态: + +- queued +- running +- success +- failed +- canceled +- dead_letter + +### 8.7.5 验收标准 + +- 失败任务可快速定位到错误原因。 +- 重试操作具备幂等保护。 +- 死信任务具备单独入口与处理记录。 + +## 8.8 监控告警 + +### 8.8.1 页面目标 + +集中查看平台服务健康、基础设施状态和异常告警。 + +### 8.8.2 功能说明 + +- 服务健康检查 +- RabbitMQ 队列积压与消费延迟 +- Worker 在线数与处理耗时 +- PostgreSQL / Redis / Qdrant / 对象存储状态 +- 告警记录与恢复状态 + +### 8.8.3 告警状态 + +- active +- recovered +- ignored + +### 8.8.4 验收标准 + +- 能定位队列积压、Worker 异常和依赖服务异常。 +- 告警支持按级别、模块、状态筛选。 +- 告警恢复后状态可追溯。 + +## 8.9 审计中心 + +### 8.9.1 页面目标 + +统一记录并查询平台关键操作、配置变更和额度变更。 + +### 8.9.2 功能说明 + +- 操作日志查询 +- 配置变更对比 +- 额度变更记录 +- 风险操作记录 +- 按人、模块、租户、时间检索 + +### 8.9.3 验收标准 + +- 关键模块操作具备完整日志。 +- 日志包含操作人、目标对象、变更前后摘要、时间和结果。 +- 风险操作日志不可被普通角色删除。 + +## 9. 数据对象与状态建议 + +### 9.1 核心对象 + +- 平台用户 +- 平台角色 +- 租户 +- 套餐 +- 额度记录 +- 模型配置 +- 模板规则 +- 平台适配器 +- 任务记录 +- 告警记录 +- 审计日志 + +### 9.2 关键设计要求 + +- 所有对象需保留 `created_by`、`updated_by`、`created_at`、`updated_at`。 +- 所有高风险对象变更需支持审计快照。 +- 涉及租户的数据对象必须带 `tenant_id`。 + +## 10. 安全与风控要求 + +- 平台管理员端建议使用独立入口与路由前缀。 +- 平台级高风险操作需二次确认,必要时增加短信或二次口令验证。 +- 敏感信息默认脱敏展示。 +- 所有导出、批量修改、冻结、重试等动作均需审计。 +- 登录、角色变更、配置发布等行为需进入审计中心。 + +## 11. V1 最小可用版本建议 + +如果以尽快支撑平台日常运营为目标,建议一期先上线以下模块: + +- 工作台 +- 租户中心 +- 套餐与额度 +- 平台用户 +- 模型中心 +- 任务运维 +- 监控告警 +- 审计中心 + +待一期稳定后,再逐步补齐: + +- 内容配置 +- 平台接入 +- 更细粒度的风险控制与审批能力 + +## 12. 最终建议 + +平台管理员端应作为独立前端应用建设,建议命名为 `ops-admin-web`,与租户端 `admin-web` 并行存在,前端技术栈可统一采用 `Vue 3 + TypeScript + ant-design-vue + pnpm + Vite 8`。 + +后端在 V1 阶段可继续复用统一 Go 服务,通过 `platform_role`、`tenant_role`、路由前缀和资源范围控制实现权限隔离,推荐按以下方式组织接口: + +- `/api/platform/*`:平台管理员端接口 +- `/api/tenant/*`:租户端接口 + +这样既能保持系统结构清晰,也能控制 V1 的研发成本与复杂度。 diff --git a/docs/geo-platform-ops-admin-tech-architecture-v1.md b/docs/geo-platform-ops-admin-tech-architecture-v1.md new file mode 100644 index 0000000..0563f84 --- /dev/null +++ b/docs/geo-platform-ops-admin-tech-architecture-v1.md @@ -0,0 +1,1512 @@ +# GEO 双后台与后端技术架构方案 V1 + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档名称 | GEO 双后台与后端技术架构方案 V1 | +| 文档版本 | V1.0 | +| 文档状态 | 待评审 | +| 创建日期 | 2026-03-25 | +| 适用范围 | `admin-web`、`ops-admin-web` 与 Go 后端整体架构设计 | +| 关联文档 | `docs/geo-platform-prd-v1.md` | +| 关联文档 | `docs/geo-platform-ops-admin-prd-v1.md` | +| 关联文档 | `docs/geo-platform-tech-architecture-v1-50k.md` | + +## 2. 目标与结论 + +### 2.1 建设目标 + +本方案用于明确 GEO 平台在 V1 阶段的双后台形态与后端落地方式,满足以下目标: + +- 面向租户提供独立的业务后台 `admin-web` +- 面向平台内部提供独立的管理后台 `ops-admin-web` +- 后端统一采用 Go 实现,控制复杂度,同时保留后续拆分能力 +- 支撑内容生成、知识学习、任务运维、额度管理、平台配置和审计能力 +- 在 V1 阶段做到“结构清晰、实现收敛、可持续演进” + +### 2.2 核心结论 + +V1 推荐采用以下架构策略: + +- 前端采用 `pnpm workspace + Vue 3 + TypeScript + ant-design-vue + Vite 8(截至 2026-03 官方稳定主线)` + +- 前端拆分为两个独立应用:`apps/admin-web` 和 `apps/ops-admin-web` + +- 公共能力通过 `packages/*` 复用,而不是让两个应用互相引用业务代码 + +- 后端采用 `Go + Gin + PostgreSQL + Redis + RabbitMQ + Qdrant + 对象存储` + +- 对象存储支持双实现:公有云部署推荐 `阿里云 OSS + CDN`,私有化部署推荐 `MinIO + Nginx / CDN` + +- 工程脚手架可使用 [`sponge`](https://github.com/go-dev-frame/sponge),但仅用于项目初始化、基础设施接线与简单样板生成;不直接生成业务 `handler/service/dao` 主体代码,领域边界仍以本文的 `transport -> app -> domain -> repository` 分层为准 + +- 后端拆分为 `gateway + tenant-api + platform-api + scheduler + 多个 Worker`,其中 `gateway` 必须保持超薄 + +- API 按路由前缀隔离:`/api/tenant/*` 与 `/api/platform/*` + +- 权限按两层建模:`tenant_role` 与 `platform_role` + +- 重任务统一异步化,任务进度通过 `SSE` 或短轮询回写 + +### 2.3 架构边界 + +| 维度 | `admin-web` | `ops-admin-web` | +| --- | --- | --- | +| 服务对象 | 租户用户 | 平台内部人员 | +| 数据范围 | 当前租户 | 全平台或指定租户 | +| 核心目标 | 生成、发布、查看内容与数据 | 管租户、管配置、管任务、管风险 | +| 权限模型 | `tenant_role` | `platform_role` | +| API 前缀 | `/api/tenant/*` | `/api/platform/*` | + +## 3. 仓库结构方案 + +### 3.1 推荐目录 + +```text +D:\project\geo +├─ apps +│ ├─ admin-web +│ ├─ ops-admin-web +│ └─ browser-extension +├─ packages +│ ├─ ui +│ ├─ shared-types +│ ├─ http-client +│ ├─ utils +│ ├─ eslint-config +│ └─ tsconfig +├─ server +│ ├─ cmd +│ │ ├─ gateway +│ │ ├─ tenant-api +│ │ ├─ platform-api +│ │ ├─ scheduler +│ │ ├─ worker-generate +│ │ ├─ worker-knowledge +│ │ ├─ worker-analytics +│ │ └─ worker-publish-callback +│ ├─ internal +│ ├─ migrations +│ ├─ sql +│ └─ pkg +├─ deploy +│ ├─ docker-compose +│ └─ k8s +└─ docs +``` + +### 3.2 前端共享包建议 + +- `packages/ui` + - 通用布局、表格筛选栏、状态标签、详情抽屉、权限按钮 +- `packages/shared-types` + - 前后端共用 DTO、分页结构、状态枚举、角色枚举 +- `packages/http-client` + - Axios 实例、鉴权拦截器、错误处理、统一响应解包 +- `packages/utils` + - 时间、金额、脱敏、下载、表格导出等工具 +- `packages/eslint-config` + - 统一 ESLint 规则 +- `packages/tsconfig` + - 统一 TS 配置基线 + +### 3.3 是否需要第三个前端应用 + +当前双后台方案覆盖了租户侧和平台侧的控制面,但租户侧 V1 的媒体绑定与手动发布还依赖独立执行面。 + +因此 V1 建议同步建设: + +- `apps/browser-extension` + +该应用不属于当前“双后台”主体,但属于 V1 必选执行端,负责真实登录检测、绑定回传、图片上传、手动发布和状态校验。 + +## 4. 前端完整方案 + +## 4.1 `apps/admin-web` 定位 + +租户端后台,主要服务于租户管理员与编辑,负责: + +- 工作台 +- 文章生成与管理 +- 品牌与知识库管理 +- 媒体账号绑定与手动发布 +- 数据追踪与套餐额度查看 + +### 4.1.1 路由建议 + +```text +/login +/workspace +/articles +/articles/:id +/brands +/knowledge +/media +/tracking +/account +``` + +### 4.1.2 页面特点 + +- 业务操作多 +- 任务状态多 +- 详情页和表单较多 +- 需要关注生成与发布的过程反馈 + +## 4.2 `apps/ops-admin-web` 定位 + +平台管理员端,主要服务于超级管理员、平台运营、客服支持、财务运营、审核风控与技术运维,负责: + +- 平台工作台 +- 租户中心 +- 套餐与额度 +- 平台用户与角色 +- 模型中心 +- 内容配置 +- 任务运维 +- 监控告警 +- 审计中心 + +### 4.2.1 路由建议 + +```text +/login +/platform/dashboard +/platform/tenants +/platform/tenants/:id +/platform/plans +/platform/platform-users +/platform/models +/platform/content +/platform/tasks +/platform/monitoring +/platform/audit +``` + +### 4.2.2 页面特点 + +- 列表、筛选、统计卡片较多 +- 高风险操作较多 +- 权限粒度更细 +- 更偏运维、审计、治理和配置管理 + +## 4.3 前端技术栈建议 + +- `vue` +- `typescript` +- `vite` +- `pnpm` +- `ant-design-vue` +- `vue-router` +- `pinia` +- `@tanstack/vue-query` +- `axios` +- `zod` +- `@vueuse/core` +- `dayjs` +- `vitest` + +## 4.4 前端工程规范 + +- 每个应用独立 `src`,共享能力全部进入 `packages` +- 菜单、按钮、路由权限全部由权限配置表驱动 +- 页面模式统一为:`筛选区 + 表格区 + 详情抽屉 / 详情页` +- 长任务状态统一使用 `状态标签 + 默认轮询`,仅在存在运行中任务的活跃页面临时升级为 `SSE` +- 高风险按钮统一带确认弹窗和原因输入框 + +## 4.5 前端部署建议 + +- `admin-web` 和 `ops-admin-web` 独立构建、独立发布 +- 推荐独立域名或子路径: + - `admin.xxx.com` + - `ops.xxx.com` +- 两套前端都走 CDN,后端统一走 API 域名 + +## 5. 后端完整方案 + +## 5.1 V1 后端总体策略 + +V1 推荐: + +- `1 个统一网关出口` +- `2 个 Go API 服务` +- `1 个 Scheduler` +- `4 类异步 Worker` +- `1 个浏览器插件执行端` +- `1 套统一基础设施` + +其中: + +- `tenant-api` 负责租户侧核心业务 +- `platform-api` 负责平台治理与内部管理 +- `gateway` 负责统一接入、路由转发、公共鉴权、租户级限流与 `request_id` 注入 + +V1 保留 `gateway`,但必须满足以下硬约束: + +- 只做 `route + auth + rate limit + request_id/trace_id 注入 + 错误响应封装` +- 不承载任何 `tenant/*` 或 `platform/*` 业务逻辑 +- 不直接写 PostgreSQL 业务表,不发 MQ 业务消息,不做任务编排 +- 若未来部署环境极简,可由 `Ingress / Nginx` 直接分流到 `tenant-api` 与 `platform-api`;本文默认仍保留超薄 `gateway` + +这样可以避免: + +- 过早微服务化 +- 大量跨服务联调 +- 权限和数据边界复杂化 +- 运维成本提前爆炸 + +同时,V1 不建议把 `worker-generate / worker-knowledge / worker-analytics / worker-publish-callback` 合并成单一 Worker 进程,因为: + +- 交互式生成链路的时延目标高于知识学习和统计聚合 +- LLM、Embedding、统计聚合的资源模型不同 +- 回调补偿链路需要与用户生成链路故障隔离 +- 运维简化更适合通过共享容器基座、统一 Helm 模板和统一日志规范实现,而不是牺牲任务隔离 + +## 5.2 Go 后端目录结构 + +```text +server +├─ cmd +│ ├─ gateway +│ ├─ tenant-api +│ ├─ platform-api +│ ├─ scheduler +│ ├─ worker-generate +│ ├─ worker-knowledge +│ ├─ worker-analytics +│ └─ worker-publish-callback +├─ internal +│ ├─ bootstrap +│ ├─ shared +│ │ ├─ auth +│ │ ├─ config +│ │ ├─ middleware +│ │ ├─ observability +│ │ ├─ plugin +│ │ ├─ retrieval +│ │ ├─ scheduler +│ │ ├─ sse +│ │ ├─ repository +│ │ │ ├─ postgres +│ │ │ ├─ redis +│ │ │ ├─ rabbitmq +│ │ │ ├─ qdrant +│ │ │ └─ objectstorage +│ │ └─ dto +│ ├─ tenant +│ │ ├─ transport +│ │ ├─ app +│ │ ├─ domain +│ │ └─ repository +│ ├─ platform +│ │ ├─ transport +│ │ ├─ app +│ │ ├─ domain +│ │ └─ repository +│ ├─ jobs +│ └─ gateway +├─ migrations +├─ sql +└─ pkg +``` + +## 5.3 API 路由分层建议 + +### 5.3.1 公共接口 + +- `/api/health/*` +- `/api/auth/*` + +### 5.3.2 租户端接口 + +- `/api/tenant/workspace/*` +- `/api/tenant/articles/*` +- `/api/tenant/brands/*` +- `/api/tenant/knowledge/*` +- `/api/tenant/media/*` +- `/api/tenant/tracking/*` +- `/api/tenant/account/*` + +### 5.3.3 平台管理员端接口 + +- `/api/platform/dashboard/*` +- `/api/platform/tenants/*` +- `/api/platform/plans/*` +- `/api/platform/platform-users/*` +- `/api/platform/models/*` +- `/api/platform/content/*` +- `/api/platform/integrations/*` +- `/api/platform/tasks/*` +- `/api/platform/monitoring/*` +- `/api/platform/audit/*` + +### 5.3.4 回调与内部接口 + +- `/api/callback/plugin/*` +- `/api/internal/metrics/*` +- `/api/internal/task-events/*` + +### 5.3.5 网关转发关系 + +- `/api/auth/*` -> `gateway` 或共享认证模块 +- `/api/tenant/*` -> `tenant-api` +- `/api/platform/*` -> `platform-api` +- `/api/callback/*` -> `tenant-api` + +## 5.4 后端二进制职责 + +| 二进制 | 作用 | +| --- | --- | +| `cmd/gateway` | 统一 API 出口、无状态路由分发、基础鉴权、租户级限流、`request_id/trace_id` 注入与统一错误响应封装 | +| `cmd/tenant-api` | 提供租户端业务接口、任务状态接口、按需 SSE、插件会话与插件回调接口 | +| `cmd/platform-api` | 提供平台管理员端接口、租户治理、模型配置、审计与监控查询 | +| `cmd/scheduler` | 扫描定时生成、数据监控等到期任务,生成调度批次并入队 | +| `cmd/worker-generate` | 处理模板生成、Prompt 生成、批量生成与文章优化任务,并负责 RAG 检索、LLM 超时/限流/并发池控制 | +| `cmd/worker-knowledge` | 处理知识学习、解析、批量 Embedding、Qdrant 写入与 chunk 元数据维护 | +| `cmd/worker-analytics` | 处理问题采集、分析快照、统计聚合、缓存刷新;默认走低优先级模型通道 | +| `cmd/worker-publish-callback` | 处理发布回调、状态回写、失败补偿 | + +## 5.5 后端模块职责 + +### 5.5.1 `shared/auth` + +- 登录 +- Token 颁发与校验 +- 用户身份识别 +- 平台角色 / 租户角色装载 + +### 5.5.2 `shared/retrieval` + +- 统一封装 query rewrite、filter 构造、Qdrant 检索、rerank 与上下文拼装 +- 为 `worker-generate` 暴露品牌知识 RAG 检索入口 +- V1 将 `retrieval_logs` 和 badcase 采样先写文件日志或对象存储,后续再升级为数据库评测体系 + +### 5.5.3 `tenant` + +- 租户基础资料 +- 租户状态 +- 租户成员与租户角色 + +### 5.5.4 `platform` + +- 平台管理员用户 +- 平台角色 +- 平台配置与平台级查询 + +### 5.5.5 `tenant/article` / `tenant/brand` / `tenant/knowledge` + +- 租户业务核心能力 +- 生成内容、品牌资料、知识库管理 + +### 5.5.6 `platform/quota` / `platform/plan` + +- 套餐配置 +- 配额消耗 +- 配额补量 +- 到期与重置规则 + +### 5.5.7 `platform/model` + +- LLM 配置 +- Embedding 配置 +- 限流策略 +- 默认模型策略 + +### 5.5.8 `tenant/task` + `platform/task` + +- 任务入队 +- 状态查询 +- 重试与终止 +- 死信处理 + +### 5.5.9 `platform/monitoring` + +- 服务健康 +- 队列状态 +- Worker 状态 +- 告警汇总 + +### 5.5.10 `platform/audit` + +- 操作日志 +- 配置变更日志 +- 额度变更日志 +- 风险操作日志 + +### 5.5.11 `shared/observability` + +- 统一结构化日志规范与日志字段 +- 统一 `request_id`、`trace_id`、`task_batch_id` 透传 +- Prometheus 指标、健康检查、慢查询与队列深度观测 +- 为 API、MQ、Worker 共用的错误码与告警标签提供基础设施 + +## 5.6 服务边界建议 + +### 5.6.1 `tenant-api` 负责 + +- 租户工作台 +- 品牌、文章、知识库 +- 媒体绑定与发布记录 +- 生成任务提交与状态查询 +- 租户侧额度消耗校验 +- 发布回调接收 + +### 5.6.2 `platform-api` 负责 + +- 平台工作台 +- 租户管理 +- 套餐与额度策略 +- 平台用户与平台角色 +- 模型配置与模板规则 +- 任务运维查询与重试控制 +- 监控告警与审计中心 + +### 5.6.3 两服务协作原则 + +- `platform-api` 不直接承载租户高频业务流量 +- `tenant-api` 不同步依赖 `platform-api` 完成核心链路 +- 平台侧对租户业务的控制,优先通过数据库规则、缓存配置或内部命令完成 +- 需要重试或控制任务时,由 `platform-api` 写控制记录并重新入队或调用内部接口 + +## 6. 认证与权限架构 + +## 6.1 统一身份模型 + +建议统一 `users` 主表,但角色绑定拆分: + +- `platform_user_roles` +- `tenant_memberships` + +这样一个账号可以: + +- 既是平台内部用户 +- 也可能是某个租户的成员 + +但两种权限链路必须分开校验。 + +## 6.2 权限模型 + +### 6.2.1 平台角色 `platform_role` + +- `super_admin` +- `ops` +- `sre` + +### 6.2.2 租户角色 `tenant_role` + +- `tenant_admin` +- `editor` + +### 6.2.3 租户角色能力边界 + +| 角色 | 主要能力 | 明确限制 | +| --- | --- | --- | +| `tenant_admin` | 租户设置、成员管理、模板/Prompt/品牌/知识/媒体、发布、配额查看、风险操作确认 | 无平台级治理权限 | +| `editor` | 创建与编辑文章、模板、Prompt、品牌、知识、媒体发布、查看追踪数据 | 不能管理成员、套餐策略、平台级配置 | + +V1 权限模型按当前产品裁剪口径收敛为 `tenant_admin + editor` 两种租户角色;`viewer` 暂不单列,若后续出现严格只读协作者场景,可再由 `editor` 细分。 + +### 6.2.4 Prompt 分享身份 `kol_profile` + +- `kol` 不是权限角色,而是用户在 Prompt 分享场景下的作者/达人身份 +- `kol` 用户可生成分享码,供普通用户导入新的“文章模板类型” +- 分享内容本质上是“模板卡片 + 变量槽位定义 + 使用说明 + 受保护 Prompt 资产引用”的快照,而不是裸 Prompt 文本 +- 导入后会在工作台或模板页形成新的模板卡片,作为平台预设模板之外的扩展文章类型 +- 公司名、地址、品牌名、联系方式等租户专属字段仍由导入方在变量槽位中自行填写或绑定 +- KOL 的原始 Prompt 正文对普通用户不可见,不可导出,不可在前端编辑,用于保护 Prompt 版权 +- `kol_profile` 通过 `prompt_share_profiles` 与 `prompt_share_codes` 建模,不参与 RBAC 鉴权 + +## 6.3 鉴权中间件顺序 + +1. Token 校验 +2. 用户身份解析 +3. 路由前缀识别 +4. 平台角色或租户角色装载 +5. 资源范围校验 +6. 风险操作校验 +7. 审计上下文注入 + +## 6.4 风险操作要求 + +以下操作建议统一纳入风险操作: + +- 冻结 / 解冻租户 +- 调整套餐额度 +- 修改模型默认策略 +- 修改敏感词规则 +- 批量重试任务 +- 修改平台适配器配置 + +这些操作至少具备: + +- 二次确认 +- 操作原因 +- 审计日志 + +## 7. 数据与中间件方案 + +## 7.1 PostgreSQL + +负责: + +- 用户、租户、成员、角色关系 +- 品牌、文章、发布记录 +- 套餐、额度、账期 +- 模型配置、模板配置 +- 任务记录 +- 告警记录 +- 审计日志 +- 配额预扣与调度租约 +- 知识 chunk 元数据 + +## 7.2 Redis + +负责: + +- 登录黑名单 +- 高频查询缓存 +- 仪表盘热点数据 +- 任务短期状态缓存 +- 限流计数 +- SSE 跨 Pod 广播 + +## 7.3 RabbitMQ + +负责: + +- 文章生成任务 +- 知识学习任务 +- 分析聚合任务 +- 发布回调任务 +- 死信队列 + +## 7.4 Qdrant + +负责: + +- 知识库向量存储 +- RAG 检索 + +## 7.5 对象存储 + +负责: + +- 原始文档 +- 图片素材 +- 导入导出文件 +- 原始抓取结果 +- 原始模型回答与插件回调归档 + +## 8. 双后台与后端交互图 + +以下为主架构的宏观交互图,细化后的闭环版本以 §13.5.1 为准。 + +```mermaid +flowchart LR + subgraph FE["前端应用"] + AW["admin-web
租户端"] + OW["ops-admin-web
平台管理员端"] + EXT["browser-extension
V1 发布执行端"] + end + + AW --> CDN["CDN / Nginx"] + OW --> CDN + + CDN --> ING["Ingress / API Gateway"] + EXT --> ING + + ING --> GW["gateway"] + GW --> TA["tenant-api"] + GW --> PA["platform-api"] + SCH["scheduler"] --> PG["PostgreSQL"] + SCH --> MQ["RabbitMQ"] + + TA --> PG["PostgreSQL"] + TA --> REDIS["Redis"] + TA --> MQ["RabbitMQ"] + TA --> OBJ["Object Storage"] + TA --> QD["Qdrant"] + + PA --> PG + PA --> REDIS + PA --> MQ + + MQ --> WG["worker-generate"] + MQ --> WK["worker-knowledge"] + MQ --> WA["worker-analytics"] + MQ --> WP["worker-publish-callback"] + + WG --> PG + WG --> REDIS + WG --> QD + + WK --> PG + WK --> QD + WK --> OBJ + + WA --> PG + WA --> REDIS + WA --> OBJ + + WP --> PG + WP --> REDIS + PA --> TA +``` + +## 9. 后端结构图 + +以下为主架构的模块示意图;二进制职责、共享模块和运行约束以 §13.5 与 §13.13 为准。 + +```mermaid +flowchart TB + subgraph CMD["cmd"] + GWBIN["gateway"] + TENBIN["tenant-api"] + PLTBIN["platform-api"] + SCHBIN["scheduler"] + GENBIN["worker-generate"] + KBBIN["worker-knowledge"] + ANABIN["worker-analytics"] + PUBBIN["worker-publish-callback"] + end + + subgraph SHARED["internal/shared"] + SAUTH["auth"] + SMIDDLE["middleware"] + SPLUGIN["plugin"] + SRET["retrieval"] + SSCHED["scheduler"] + SREPO["shared repositories"] + SSSE["sse"] + SOBS["observability"] + end + + subgraph TENANT["internal/tenant"] + TTRANS["transport"] + TAPP["app"] + TDOMAIN["domain"] + TREPO["repository"] + end + + subgraph PLATFORM["internal/platform"] + PTRANS["transport"] + PAPP["app"] + PDOMAIN["domain"] + PREPO["repository"] + end + + subgraph JOBS["internal/jobs"] + JGEN["generate job"] + JKB["knowledge job"] + JANA["analytics job"] + JPUB["publish callback job"] + end + + subgraph INFRA["infra"] + PGREPO["postgres"] + REDISREPO["redis"] + MQREPO["rabbitmq"] + QDREPO["qdrant"] + OBJREPO["objectstorage"] + end + + GWBIN --> SAUTH + GWBIN --> SMIDDLE + + TENBIN --> TTRANS + TENBIN --> SAUTH + TENBIN --> SPLUGIN + TENBIN --> SSSE + TENBIN --> SOBS + + PLTBIN --> PTRANS + PLTBIN --> SAUTH + PLTBIN --> SOBS + + SCHBIN --> SSCHED + SCHBIN --> SOBS + + TTRANS --> TAPP + TAPP --> TDOMAIN + TAPP --> TREPO + + PTRANS --> PAPP + PAPP --> PDOMAIN + PAPP --> PREPO + + GENBIN --> JGEN + KBBIN --> JKB + ANABIN --> JANA + PUBBIN --> JPUB + + JGEN --> SRET + JGEN --> TREPO + JKB --> TREPO + JANA --> PREPO + JPUB --> TREPO + JPUB --> PREPO + + TREPO --> SREPO + PREPO --> SREPO + + SRET --> SREPO + SREPO --> PGREPO + SREPO --> REDISREPO + SREPO --> MQREPO + SREPO --> QDREPO + SREPO --> OBJREPO +``` + +## 10. 核心链路设计 + +## 10.1 租户端生成链路 + +1. `admin-web` 提交生成请求 +2. API 校验租户身份、额度、参数 +3. `tenant-api` 在同一 PostgreSQL 事务内完成“额度预扣 / 保留 + `generation_tasks` / `task_records` 创建 + 文章占位记录初始化” +4. 事务提交后写入 RabbitMQ +5. `worker-generate` 消费任务,先经 `shared/retrieval` 执行 `query rewrite -> filter -> Qdrant 检索 -> rerank -> 上下文拼装` +6. `worker-generate` 在超时、限流、并发池保护下调用模型 +7. 成功时在同一 PostgreSQL 事务内写入 `article_versions`、更新 `articles`、确认额度消耗;失败时写失败状态并按条退还额度 +8. 结果写入 Redis,前端通过 SSE 或轮询感知任务进度 + +## 10.2 平台端额度调整链路 + +1. `ops-admin-web` 发起额度变更 +2. API 校验 `platform_role` +3. 写入额度变更记录 +4. 更新租户额度表与缓存 +5. 写入审计日志 +6. 租户端下一次请求按新额度生效 + +## 10.3 平台端任务排查链路 + +1. `ops-admin-web` 查询失败任务列表 +2. API 聚合 PostgreSQL 任务信息、错误信息与重试信息 +3. 平台人员发起重试 +4. API 写入重试记录并重新入队 +5. Worker 执行后回写结果 +6. 审计中心记录操作人和结果 + +## 10.4 监控与告警链路 + +1. Go API 与 Worker 暴露指标 +2. 监控系统采集服务、队列、缓存、数据库指标 +3. 告警服务生成异常记录 +4. `ops-admin-web` 查询并展示告警状态 + +## 11. 部署建议 + +## 11.1 本地开发 + +- 前端:`pnpm` 工作区本地联调 +- 基础设施:`docker compose` 仅启动 PostgreSQL、Redis、RabbitMQ、Qdrant、MinIO +- 后端服务建议本地 `go run`,按需启动: + - `go run ./server/cmd/gateway` + - `go run ./server/cmd/tenant-api` + - `go run ./server/cmd/platform-api` + - `go run ./server/cmd/scheduler` + - `go run ./server/cmd/worker-generate` + - `go run ./server/cmd/worker-knowledge` + - `go run ./server/cmd/worker-analytics` + - `go run ./server/cmd/worker-publish-callback` +- 建议提供 `make dev-init` 或等价任务脚本,统一完成: + - 执行 migration + - 创建 RabbitMQ 队列 / exchange / routing key + - 初始化 MinIO bucket + - 写入最小种子数据与本地模型通道配置 + +## 11.2 测试与预发 + +- 前端独立构建产物 +- API 与 Worker 独立容器 +- 使用共享测试基础设施或云托管实例 + +## 11.3 线上生产 + +- `admin-web` 与 `ops-admin-web` 独立发布到 CDN +- API 与 Worker 部署到 Kubernetes +- PostgreSQL、Redis、RabbitMQ、Qdrant 使用高可用或托管方案 + +## 12. 演进路线 + +### 12.1 V1 + +- 两个前端应用 +- 一个浏览器插件执行端 +- 一个统一网关出口 +- 两个 Go API 服务 +- 一个 Scheduler +- 四类 Worker +- 平台端与租户端路由隔离 + +### 12.2 V1.5 + +- 抽离统一审计模块 +- 抽离模型网关模块 +- 增加平台接入配置中心 + +### 12.3 V2 + +- 按压力拆分平台 API 与租户 API +- 单独拆出 `ops-bff` 或 `platform-api` +- 单独拆出 `analytics-service` + +## 13. V1 闭环补齐方案 + +### 13.1 适用说明 + +本节结合以下材料对前文进行补齐修订,并作为 V1 详细落地的统一口径: + +- `docs/refer/*` +- `docs/geo-platform-prd-v1.md` +- `docs/geo-platform-ops-admin-prd-v1.md` +- `docs/media-binding-and-publish-v1.md` +- `docs/question-driven-monitoring-design-v1.md` +- `docs/Claude_architecture_review.md` + +若本节与前文较早的抽象表述存在冲突,以本节为准。 + +### 13.2 修订结论 + +为保证 `docs/refer` 中 8 个页面在 V1 阶段形成“页面 -> 路由 -> API -> 对象 -> 任务 -> 存储 -> 状态回传”的完整闭环,V1 对总体架构做如下修订: + +- `apps/browser-extension` 从“后续可选”提升为 `V1 必选执行端` +- 后端实际形态明确为:`gateway + tenant-api + platform-api + scheduler + 4 类 Worker + browser-extension` +- `tenant-api` 按业务拆分为 `workspace / template / prompt-rule / schedule / article / optimization / brand / knowledge / media / tracking / account` +- `worker-generate` 同时承载 `模板生成 / Prompt 生成 / 批量生成 / 文章优化` +- 工作台模板卡片支持“平台预设 + 租户自建 + KOL 分享码导入”三种来源,导入后统一物化为租户模板 +- `数据追踪` 采用“问题驱动监控”模型,最小采集单元为 `question_version x model x run_time` +- 工作台、数据追踪、任务状态统一提供聚合接口,避免前端首屏发起过多散请求 +- 所有长任务统一接入 `task_records + SSE + 轮询兜底` + +### 13.3 功能闭环矩阵 + +| 功能页 | 核心路由 | 核心 API 组 | 核心对象 | 异步/执行组件 | +| --- | --- | --- | --- | --- | +| 工作台 | `/workspace` | `/api/tenant/workspace/*` | `articles` `article_templates` `brands` `platform_accounts` `tenant_plan_subscriptions` | `worker-generate` `worker-publish-callback` | +| 模板创作 | `/articles/template` | `/api/tenant/templates/*` `/api/tenant/prompt-shares/*` `/api/tenant/articles/*` | `article_templates` `prompt_share_codes` `articles` `generation_tasks` | `worker-generate` | +| 自定义生成 | `/articles/custom/*` | `/api/tenant/prompt-rules/*` `/api/tenant/schedules/*` `/api/tenant/articles/*` | `prompt_rules` `schedule_tasks` `generation_tasks` | `scheduler` `worker-generate` | +| 文章优化 | `/articles/optimize` | `/api/tenant/optimizations/*` `/api/tenant/articles/*` | `optimization_tasks` `article_versions` | `worker-generate` | +| 媒体管理 | `/media` | `/api/tenant/media/*` `/api/callback/plugin/*` | `media_platforms` `platform_accounts` `plugin_sessions` `publish_records` | `browser-extension` `worker-publish-callback` | +| 品牌词库 | `/brands` `/brands/:id` | `/api/tenant/brands/*` | `brands` `brand_keywords` `brand_questions` `brand_question_versions` `competitors` | `worker-analytics` | +| 知识库 | `/knowledge` | `/api/tenant/knowledge/*` | `knowledge_groups` `knowledge_items` `knowledge_parse_tasks` | `worker-knowledge` | +| 数据追踪 | `/tracking/overview` `/tracking/detail` | `/api/tenant/tracking/*` | `question_monitor_runs` `question_monitor_parse_results` `keyword_model_daily_metrics` `keyword_model_top_questions_daily` `keyword_model_citation_rank_daily` | `scheduler` `worker-analytics` | + +### 13.4 细化前端路由 + +#### 13.4.1 `admin-web` + +```text +/login +/workspace +/articles/template +/articles/template/create +/articles/custom +/articles/custom/instant +/articles/custom/scheduled +/articles/custom/prompts +/articles/optimize +/articles/:id +/articles/:id/publish-records +/brands +/brands/:id +/knowledge +/media +/tracking/overview +/tracking/detail +/account +``` + +#### 13.4.2 `ops-admin-web` + +```text +/login +/platform/dashboard +/platform/tenants +/platform/tenants/:id +/platform/plans +/platform/platform-users +/platform/platform-roles +/platform/models/llm +/platform/models/embedding +/platform/models/channels +/platform/models/limits +/platform/content/templates +/platform/content/prompts +/platform/content/tags +/platform/content/sensitive-rules +/platform/integrations/media-platforms +/platform/integrations/adapters +/platform/integrations/publish-policies +/platform/tasks/generation +/platform/tasks/knowledge +/platform/tasks/publish +/platform/tasks/analytics +/platform/tasks/dead-letter +/platform/monitoring/services +/platform/monitoring/queues +/platform/monitoring/workers +/platform/monitoring/storage +/platform/monitoring/alerts +/platform/audit/operations +/platform/audit/config-changes +/platform/audit/quota-changes +/platform/audit/risk-actions +``` + +### 13.5 服务、二进制与模块补齐 + +#### 13.5.1 修订后的整体交互图 + +```mermaid +flowchart LR + AW["admin-web"] --> CDN["CDN / Nginx"] + OW["ops-admin-web"] --> CDN + EXT["browser-extension"] <---> AW + + CDN --> GW["gateway"] + GW --> TA["tenant-api"] + GW --> PA["platform-api"] + + SCH["scheduler"] --> PG["PostgreSQL"] + SCH --> MQ["RabbitMQ"] + + TA --> PG + TA --> REDIS["Redis"] + TA --> MQ + TA --> OBJ["Object Storage"] + TA --> QD["Qdrant"] + + PA --> PG + PA --> REDIS + PA --> MQ + + EXT --> CB["/api/callback/plugin/*"] + CB --> TA + + MQ --> WG["worker-generate"] + MQ --> WK["worker-knowledge"] + MQ --> WA["worker-analytics"] + MQ --> WP["worker-publish-callback"] + + WG --> QD + WA --> OBJ +``` + +#### 13.5.2 二进制职责补齐 + +| 二进制/执行端 | 职责 | +| --- | --- | +| `cmd/gateway` | 统一入口、公共鉴权、限流、`request_id/trace_id` 注入与统一错误响应封装;保持无状态,支持水平扩容 | +| `cmd/tenant-api` | 承载租户侧全部业务接口、SSE、插件会话管理、回调落库 | +| `cmd/platform-api` | 承载平台治理、套餐额度、平台用户、模型中心、平台接入、监控审计 | +| `cmd/scheduler` | 扫描 `schedule_tasks` 和数据监控计划,按到期窗口生成调度批次并入队 | +| `cmd/worker-generate` | 执行模板生成、Prompt 生成、批量生成、文章优化;通过 `shared/retrieval` 做 RAG 检索,并使用独立 LLM 并发池控制交互任务 | +| `cmd/worker-knowledge` | 执行文档解析、URL 抓取、文本切片、批量 Embedding、Qdrant 写入、chunk 元数据维护 | +| `cmd/worker-analytics` | 执行问题驱动监控、回答解析、日级聚合、排行榜刷新、缓存刷新;采集任务默认走低优先级模型通道 | +| `cmd/worker-publish-callback` | 处理发布回调补偿、账号状态检测结果、失败重放与死信回写 | +| `apps/browser-extension` | 真实登录检测、账号绑定、图片上传、手动发布、状态检查、本地 adapter 执行 | + +#### 13.5.3 租户侧模块补齐 + +| 模块 | 职责 | +| --- | --- | +| `tenant/workspace` | 工作台聚合指标、最近生成、额度摘要、模板卡片下发;卡片来源支持平台预设、租户自建与 KOL 分享导入 | +| `tenant/template` | 模板定义、模板 schema、模板版本、模板生成入口、KOL 分享模板的变量定制与落库;不向导入方暴露受保护 Prompt 正文 | +| `tenant/prompt-rule` | Prompt 规则 CRUD、启停、默认语气/字数配置、作者身份信息、分享码导出与导入;支持变量槽位定义与版权保护 | +| `tenant/schedule` | 定时生成计划 CRUD、启停、下一次执行时间维护 | +| `tenant/article` | 文章主记录、详情、预览、复制、删除、发布前读取 | +| `tenant/optimization` | 优化任务、优化方向、版本保存、未完成任务入口 | +| `tenant/brand` | 品牌、关键词、问题集、问题版本、竞品库 | +| `tenant/knowledge` | 分组、知识条目、来源类型、学习任务状态 | +| `tenant/media` | 平台清单、平台账号、插件会话、发送记录、状态检测 | +| `tenant/tracking` | 数据概览、趋势图、高频问题、引用排行、品牌关键词模型筛选 | +| `tenant/account` | 套餐、额度、重置时间、升级入口占位 | + +#### 13.5.4 平台侧模块补齐 + +| 模块 | 职责 | +| --- | --- | +| `platform/tenant` | 租户开通、冻结解冻、套餐关联、额度治理 | +| `platform/plan` + `platform/quota` | 套餐档位、额度策略、补量、账期重置 | +| `platform/model` | LLM、Embedding、渠道密钥、限流、默认策略 | +| `platform/content` | 平台级模板、Prompt 规则、分类标签、敏感词规则 | +| `platform/integration` | 媒体平台清单、平台分类、adapter 注册、发布策略 | +| `platform/task` | 任务运维、批量重试、死信处理、执行历史 | +| `platform/monitoring` | 服务、队列、Worker、存储、告警 | +| `platform/audit` | 操作日志、配置变更、额度变更、风险操作 | + +### 13.6 核心 API 补齐 + +#### 13.6.1 租户侧 API + +- `/api/tenant/workspace/overview` +- `/api/tenant/workspace/recent-articles` +- `/api/tenant/workspace/quota-summary` +- `/api/tenant/workspace/template-cards` +- `/api/tenant/templates` +- `/api/tenant/templates/{id}` +- `/api/tenant/templates/{id}/schema` +- `/api/tenant/templates/{id}/generate` +- `/api/tenant/prompt-rules` +- `/api/tenant/prompt-rules/{id}` +- `/api/tenant/prompt-rules/{id}/share-code` +- `/api/tenant/prompt-shares/resolve/{shareCode}` +- `/api/tenant/prompt-shares/import` +- `/api/tenant/prompt-share-profiles/me` +- `/api/tenant/schedules` +- `/api/tenant/schedules/{id}` +- `/api/tenant/schedules/{id}/enable` +- `/api/tenant/schedules/{id}/disable` +- `/api/tenant/articles` +- `/api/tenant/articles/{id}` +- `/api/tenant/articles/{id}/versions` +- `/api/tenant/articles/{id}/publish-records` +- `/api/tenant/optimizations` +- `/api/tenant/optimizations/{id}` +- `/api/tenant/brands` +- `/api/tenant/brands/{id}` +- `/api/tenant/brands/{id}/keywords` +- `/api/tenant/brands/{id}/questions` +- `/api/tenant/brands/{id}/question-versions` +- `/api/tenant/brands/{id}/competitors` +- `/api/tenant/knowledge/groups` +- `/api/tenant/knowledge/items` +- `/api/tenant/knowledge/parse-tasks` +- `/api/tenant/media/platforms` +- `/api/tenant/media/platform-accounts` +- `/api/tenant/media/platform-accounts/{id}/check` +- `/api/tenant/media/plugin-sessions` +- `/api/tenant/media/publish-records` +- `/api/tenant/tracking/summary` +- `/api/tenant/tracking/trend` +- `/api/tenant/tracking/top-questions` +- `/api/tenant/tracking/citation-rank` +- `/api/tenant/account/plan` +- `/api/tenant/account/quota` + +#### 13.6.2 平台侧 API + +- `/api/platform/dashboard/*` +- `/api/platform/tenants/*` +- `/api/platform/plans/*` +- `/api/platform/platform-users/*` +- `/api/platform/models/*` +- `/api/platform/content/*` +- `/api/platform/integrations/*` +- `/api/platform/tasks/*` +- `/api/platform/monitoring/*` +- `/api/platform/audit/*` + +#### 13.6.3 插件回调与内部接口 + +- `/api/callback/plugin/bind` +- `/api/callback/plugin/publish` +- `/api/callback/plugin/check` +- `/api/internal/task-events` +- `/api/internal/scheduler/dispatch` + +#### 13.6.4 统一错误响应与错误码 + +所有 API 建议统一返回: + +```json +{ + "code": 40301, + "message": "quota_insufficient", + "detail": "生成额度不足,请升级套餐或等待额度重置", + "request_id": "req_xxx" +} +``` + +错误码段建议如下: + +| 错误码段 | 含义 | +| --- | --- | +| `400xx` | 参数校验与请求格式错误 | +| `401xx` | 认证失败、Token 失效 | +| `403xx` | 权限不足、额度不足、功能未开通 | +| `404xx` | 资源不存在 | +| `409xx` | 状态冲突、幂等冲突、重复提交 | +| `429xx` | 频率限制、队列拥塞 | +| `500xx` | 内部错误 | +| `503xx` | 下游依赖不可用,如 `LLM / Embedding / Qdrant / ObjectStorage` | + +`gateway` 与各 API 服务必须共用同一套错误码枚举和响应封装,避免前后端联调时出现多种格式。 + +#### 13.6.5 KOL 分享码解析边界 + +- `/api/tenant/prompt-shares/resolve/{shareCode}` 只返回模板卡片信息、变量槽位、使用说明、作者展示信息,不返回底层 Prompt 正文 +- `/api/tenant/prompt-shares/import` 在服务端完成模板物化,前端只提交变量定制结果,不接触受保护 Prompt 资产 +- `/api/tenant/templates/{id}` 对 `origin_type=kol_share` 的模板仅返回可定制字段与运行配置,不返回 `prompt_template` +- 真正参与生成的受保护 Prompt 仅由 `tenant-api` / `worker-generate` 在服务端读取,前端与浏览器插件都不可见 + +### 13.7 异步任务、调度与幂等设计 + +#### 13.7.1 队列与任务类型 + +| 队列 | 任务类型 | 说明 | +| --- | --- | --- | +| `q.generate.interactive` | `article.generate.template` `article.generate.prompt` `article.optimize` | 交互式任务,高优先级,服务工作台、模板创作、自定义生成即时任务与文章优化 | +| `q.generate.batch` | `article.generate.batch_item` `article.generate.schedule_item` | 批量与定时任务,低优先级,避免堵塞交互任务 | +| `q.knowledge` | `knowledge.parse.file` `knowledge.parse.url` `knowledge.parse.text` | 不同来源共用解析入口,按来源类型走不同解析器 | +| `q.analytics.collect` | `analytics.collect.question` | 采集任务,低于用户直触发生成任务优先级,默认使用独立模型通道或更小并发池 | +| `q.analytics.aggregate` | `analytics.aggregate.daily` `analytics.refresh.cache` | 聚合与缓存刷新任务,不占用 LLM 通道 | +| `q.publish-callback` | `publish.persist` `platform-account.check.persist` | 插件执行结果、账号检测结果入队补偿 | +| `q.dead-letter` | `dead_letter.*` | 统一兜底失败任务 | + +#### 13.7.2 调度器设计 + +- `scheduler` 每分钟扫描一次 `schedule_tasks`,条件为 `status=enabled and next_run_at <= now()` +- 使用数据库行级锁或租约字段 `dispatch_lease_until + lease_owner` 防止多实例重复派发 +- 每次派发生成一个 `task_batch_id`,并为批量任务拆分出多个 `generation_tasks` +- 数据追踪监控计划同样由 `scheduler` 生成 `analytics.collect.question` 任务 +- 计划任务创建时默认写入 `dispatch_jitter_minutes`,将大批同时到期任务平滑散布到 `5~10 分钟` 窗口内 +- `scheduler` 重启后按 `task_batch_id + request_hash` 幂等追回漏派发任务,避免重复投递 +- V1 不依赖 RabbitMQ Delay Queue,统一采用“数据库到期扫描 + 普通消息队列”方式,降低实现复杂度 + +#### 13.7.3 事务、幂等与补偿 + +- 任务提交路径必须在单个 PostgreSQL 事务内完成“额度预扣 / 保留 + 任务创建 + 任务记录写入” +- Worker 成功路径必须在单个 PostgreSQL 事务内完成“结果写入 + 主记录状态更新 + 额度确认” +- Worker 失败路径必须在单个 PostgreSQL 事务内完成“失败状态更新 + 额度退还 / 释放” +- 批量生成采用“提交时按批次预扣 + 单条失败退款 + 定期对账”模式,批次维度以 `task_batch_id` 管理 +- 生成任务按 `tenant_id + source_type + request_hash + schedule_slot` 做幂等 +- 插件回调按 `plugin_session_id + callback_stage` 做幂等 +- `publish_records` 保留请求与响应摘要,支持补偿重放 +- Worker 默认重试 3 次,超过阈值进入死信;平台端允许人工批量重试 + +### 13.8 浏览器插件执行面闭环 + +#### 13.8.1 插件会话模型 + +为避免“页面一关结果丢失”,V1 引入 `plugin_sessions`: + +1. 前端先调用 `/api/tenant/media/plugin-sessions` 创建一次性会话 +2. 后端返回 `plugin_session_id + session_token + expire_at` +3. 前端将会话信息交给 `browser-extension` +4. 插件执行绑定、检测或发布动作 +5. 插件将结果: + - 回传给前端用于即时交互 + - 同时回调 `/api/callback/plugin/*` 用于可靠落库 +6. `tenant-api` 校验 `session_token` 后写入 `platform_accounts` 或 `publish_records` +7. 系统通过 SSE 广播结果,列表页和详情页实时刷新 + +#### 13.8.2 绑定/发布状态流转 + +- 平台账号状态:`pending -> active -> invalid/expired` +- 发布状态:`unpublished -> publishing -> success/failed/pending_review` +- 回调接口不直接信任浏览器 Cookie,只信任后端签发的短期 `session_token` + +#### 13.8.3 安全约束 + +- 服务端不保存第三方平台 Cookie 与登录态 +- 插件仅允许注入受信后台域名 +- 所有 `postMessage` / `runtime.sendMessage` 必须校验来源域名与会话 token +- 所有绑定、重检、发布、失败补偿动作写入审计日志 + +#### 13.8.4 插件检测与降级 + +- `packages/shared-types` 维护 `MIN_BROWSER_EXTENSION_VERSION` 常量,作为前后端与插件共享的最低兼容版本 +- 媒体页加载时,`admin-web` 先通过 `plugin.ping` / `postMessage` 握手检测插件是否安装、版本是否满足要求、能力清单是否齐备 +- 未安装、被禁用或版本过低时,页面将“去绑定 / 重新检测 / 发布”按钮置灰,并展示安装或升级引导 +- 插件回调会把 `client_version` 回传到 `plugin_sessions` 审计字段,便于排查兼容问题 +- 降级场景下不走伪成功逻辑,统一保持前端只读提示,避免产生无法落库的假状态 + +### 13.9 数据追踪闭环 + +#### 13.9.1 采集模型 + +数据追踪采用 `问题驱动监控`,最小采集单元为: + +`1 question_version x 1 model x 1 run_time = 1 条原始监控记录` + +含义如下: + +- `品牌` 是租户下的监控主体 +- `关键词` 是问题集合容器与聚合维度 +- `问题版本` 保证历史口径稳定 +- `模型` 是对比维度 +- 页面只查日级聚合表,不直接扫描原始回答 + +#### 13.9.2 数据链路 + +1. 用户在品牌词库维护 `brand_keywords` 与 `brand_questions` +2. `scheduler` 按每日频率为启用的问题版本和目标模型派发监控任务 +3. `worker-analytics` 调用模型得到原始回答,原文写入对象存储 +4. 解析回答是否提及品牌、是否首位提及、是否首选推荐、是否正向提及、是否引用文章 +5. 结果写入 `question_monitor_runs` 与 `question_monitor_parse_results` +6. 日级聚合刷新 `question_model_daily_metrics`、`keyword_model_daily_metrics`、`keyword_model_top_questions_daily`、`keyword_model_citation_rank_daily` +7. Redis 缓存 30 天摘要、趋势图、高频问题、引用排行 +8. `/api/tenant/tracking/*` 统一从聚合表和缓存读取 + +#### 13.9.3 V1 控制策略 + +- 每个问题每天默认执行 `1 次` +- 重要品牌或更高套餐可配置为 `2 次` +- 同品牌、同模型、同日、相同 `question_hash` 可做采集去重 +- 原始回答落对象存储,PostgreSQL 仅保存结构化解析与聚合结果 +- 原始运行记录保留 `30 ~ 90 天`,解析结果保留 `90 天`,日级汇总保留 `180 天+` +- `brand_question_versions` 仅在“启用 / 发布新监控口径”时生成新版本;编辑态默认做 `10 分钟` debounce,避免碎片化 + +### 13.10 SSE 与轮询策略 + +#### 13.10.1 SSE 适用场景 + +以下场景在“存在运行中任务”期间可临时升级为 SSE: + +- 文章生成进度 +- 文章优化进度 +- 知识学习状态 +- 平台账号检测状态 +- 发布状态变更 +- 配额刷新提醒 + +#### 13.10.2 事件类型 + +- `task.updated` +- `article.generated` +- `article.optimized` +- `knowledge.updated` +- `platform_account.updated` +- `publish.updated` +- `quota.updated` + +统一负载字段建议: + +- `event_id` +- `tenant_id` +- `event_type` +- `resource_type` +- `resource_id` +- `task_id` +- `status` +- `message` +- `occurred_at` + +#### 13.10.3 连接策略 + +- 工作台、列表页、数据页默认使用 `10s` 轮询 +- 仅当页面存在 `queued/running/publishing/checking` 状态的资源时,前端才建立 SSE 连接 +- 单用户单租户默认只保留 `1` 条 SSE 连接;页面空闲 `60s` 且无运行中任务时主动关闭 +- `tenant-api` 通过 Redis Pub/Sub 做跨 Pod 事件广播,不依赖 Sticky Session;任一 Pod 写事件后,所有订阅 Pod 都能向本地连接转发 +- SSE heartbeat 调整为 `30s` +- 客户端断线后按 `3s / 5s / 10s` 退避重连 +- 若浏览器或网络环境不支持 SSE,则保持 `10s` 轮询 +- 页面不直接监听 RabbitMQ,统一由 `tenant-api` 聚合并对前端推送 + +### 13.11 核心数据模型与最小 Schema + +#### 13.11.1 身份、租户与额度 + +| 表 | 关键字段 | 关键约束/索引 | +| --- | --- | --- | +| `tenants` | `id` `name` `status` `created_at` `frozen_at` | `uk_tenant_name` | +| `users` | `id` `email` `phone` `status` `created_at` | `uk_users_email` | +| `tenant_memberships` | `id` `tenant_id` `user_id` `tenant_role` | `uk_tenant_user` | +| `platform_user_roles` | `id` `user_id` `platform_role` | `uk_platform_user_role` | +| `plans` | `id` `plan_code` `name` `quota_policy_json` `status` | `uk_plan_code` | +| `tenant_plan_subscriptions` | `id` `tenant_id` `plan_id` `start_at` `end_at` `status` | `idx_tenant_plan_active` | +| `tenant_quota_ledgers` | `id` `tenant_id` `quota_type` `delta` `balance_after` `reason` | `idx_tenant_quota_tenant_type_created` | +| `quota_reservations` | `id` `tenant_id` `quota_type` `resource_type` `resource_id` `reserved_amount` `consumed_amount` `refunded_amount` `status` `expire_at` | `idx_quota_reservation_tenant_status` | + +#### 13.11.2 模板、Prompt、文章与优化 + +| 表 | 关键字段 | 关键约束/索引 | +| --- | --- | --- | +| `article_templates` | `id` `scope` `tenant_id` `origin_type` `share_code_id` `template_key` `template_name` `schema_json` `prompt_template` `prompt_visibility` `protected_prompt_asset_key` `card_config_json` `status` `version_no` | `uk_template_scope_key_version` | +| `prompt_rules` | `id` `tenant_id` `name` `prompt_content` `scene` `default_tone` `default_word_count` `status` | `idx_prompt_rule_tenant_status` | +| `prompt_share_profiles` | `id` `user_id` `display_name` `bio` `avatar_url` `share_enabled` `status` | `uk_prompt_share_profile_user` | +| `prompt_share_codes` | `id` `tenant_id` `source_template_id` `owner_user_id` `share_code` `snapshot_json` `protected_prompt_asset_key` `status` `expire_at` | `uk_prompt_share_code` | +| `schedule_tasks` | `id` `tenant_id` `prompt_rule_id` `target_platform_id` `cron_expr` `next_run_at` `dispatch_jitter_minutes` `lease_owner` `dispatch_lease_until` `last_dispatch_at` `status` | `idx_schedule_due` | +| `articles` | `id` `tenant_id` `source_type` `template_id` `prompt_rule_id` `current_version_id` `generate_status` `publish_status` | `idx_article_tenant_created` | +| `article_versions` | `id` `article_id` `version_no` `title` `html_content` `markdown_content` `word_count` `source_label` | `uk_article_version_no` | +| `generation_tasks` | `id` `tenant_id` `article_id` `task_batch_id` `quota_reservation_id` `task_type` `request_hash` `status` `error_message` | `idx_generation_task_status_created` | +| `optimization_tasks` | `id` `tenant_id` `article_id` `source_version_id` `target_version_id` `optimization_type` `status` | `idx_optimization_task_status` | + +其中:`article_templates.scope=platform` 时 `tenant_id` 为空;`scope=tenant` 时 `tenant_id` 必填。 + +导入 KOL 分享码后,系统会把快照物化为当前租户自己的 `article_templates` 记录,并标记 `origin_type=kol_share`。 + +`article_templates.prompt_visibility=sealed` 时,模板的 Prompt 正文只允许服务端读取,前端接口不得返回。 + +`prompt_share_codes.snapshot_json` 只保存模板卡片信息、变量占位和使用说明;真正的 Prompt 正文通过 `protected_prompt_asset_key` 以受保护资产方式保存,不复制导出方租户的品牌、地址、媒体账号等私有数据。 + +#### 13.11.3 品牌与问题集 + +| 表 | 关键字段 | 关键约束/索引 | +| --- | --- | --- | +| `brands` | `id` `tenant_id` `name` `description` `status` | `uk_brand_tenant_name` | +| `brand_keywords` | `id` `tenant_id` `brand_id` `name` `status` | `uk_brand_keyword_name` | +| `brand_questions` | `id` `tenant_id` `brand_id` `keyword_id` `current_version_id` `status` | `idx_brand_questions_keyword` | +| `brand_question_versions` | `id` `question_id` `question_text` `question_hash` `version_no` `is_active` | `uk_question_version_no` `idx_question_hash` | +| `competitors` | `id` `tenant_id` `brand_id` `name` `website` `description` `product_lines_json` | `idx_competitor_brand` | + +#### 13.11.4 知识库 + +| 表 | 关键字段 | 关键约束/索引 | +| --- | --- | --- | +| `knowledge_groups` | `id` `tenant_id` `name` `parent_id` `sort_order` | `uk_knowledge_group_name` | +| `knowledge_items` | `id` `tenant_id` `group_id` `source_type` `name` `storage_key` `status` `size_bytes` | `idx_knowledge_item_group_status` | +| `knowledge_parse_tasks` | `id` `tenant_id` `knowledge_item_id` `source_type` `status` `error_message` | `idx_parse_task_status` | +| `knowledge_chunks_meta` | `id` `tenant_id` `knowledge_item_id` `chunk_index` `token_count` `qdrant_point_id` `item_version` `status` | `idx_chunk_item_version` | + +V1 的 `retrieval_logs` 与 `rag_eval_cases` 先不落数据库表,统一写文件日志或对象存储采样归档,待评测体系成熟后再升级。 + +#### 13.11.5 媒体平台与插件执行 + +| 表 | 关键字段 | 关键约束/索引 | +| --- | --- | --- | +| `media_platforms` | `id` `platform_code` `name` `category` `adapter_key` `status` | `uk_media_platform_code` | +| `platform_accounts` | `id` `tenant_id` `user_id` `platform_id` `platform_uid` `nickname` `avatar_url` `status` `last_check_at` | `uk_platform_account_identity` | +| `plugin_sessions` | `id` `tenant_id` `user_id` `action_type` `platform_id` `session_token` `client_version` `expire_at` `status` | `uk_plugin_session_token` `idx_plugin_session_expire` | +| `publish_records` | `id` `tenant_id` `article_id` `platform_account_id` `platform_id` `status` `external_article_id` `error_message` `request_payload_json` `response_payload_json` | `idx_publish_record_article_created` | + +#### 13.11.6 数据追踪 + +| 表 | 关键字段 | 关键约束/索引 | +| --- | --- | --- | +| `question_monitor_runs` | `id` `tenant_id` `brand_id` `keyword_id` `question_id` `question_version_id` `model_id` `run_date` `run_at` `task_batch_id` `status` `raw_answer_storage_key` | `idx_monitor_runs_query` | +| `question_monitor_parse_results` | `id` `run_id` `mentioned_brand` `top1_mentioned` `first_recommended` `positive_mentioned` `citation_article_count` `citation_platform_count` `parser_version` | `uk_parse_result_run` | +| `question_model_daily_metrics` | `tenant_id` `brand_id` `keyword_id` `question_id` `question_version_id` `model_id` `date` `ask_count` `mention_count` `top1_mention_count` `first_recommend_count` `positive_mention_count` `citation_count` | `uk_question_model_daily` | +| `keyword_model_daily_metrics` | `tenant_id` `brand_id` `keyword_id` `model_id` `date` `ask_count` `mention_rate` `positive_mention_rate` | `uk_keyword_model_daily` | +| `keyword_model_top_questions_daily` | `tenant_id` `brand_id` `keyword_id` `model_id` `date` `question_id` `score` | `idx_top_questions_daily` | +| `keyword_model_citation_rank_daily` | `tenant_id` `brand_id` `keyword_id` `model_id` `date` `cited_article_id` `citation_count` | `idx_citation_rank_daily` | + +保留策略建议与专项监控方案对齐:原始运行记录 `30 ~ 90 天`、解析结果 `90 天`、日级汇总 `180 天或更久`。 + +#### 13.11.7 平台任务与审计 + +| 表 | 关键字段 | 关键约束/索引 | +| --- | --- | --- | +| `task_records` | `id` `tenant_id` `task_type` `resource_type` `resource_id` `status` `retry_count` `task_batch_id` | `idx_task_record_status_type` | +| `audit_logs` | `id` `operator_id` `tenant_id` `module` `action` `before_json` `after_json` `result` `created_at` | `idx_audit_module_created` | +| `config_change_logs` | `id` `operator_id` `scope_type` `scope_id` `change_type` `diff_json` | `idx_config_change_scope` | +| `quota_change_logs` | `id` `operator_id` `tenant_id` `quota_type` `delta` `reason` | `idx_quota_change_tenant` | +| `risk_action_logs` | `id` `operator_id` `tenant_id` `risk_type` `reason` `result` | `idx_risk_action_tenant_created` | + +### 13.12 闭环验收标准 + +以下标准满足时,可认为主技术架构已从“宏观方案”补齐为“可开发闭环方案”: + +- `工作台` 有独立聚合 API,不依赖前端拼装多个分散接口 +- `模板创作` 有模板定义、schema、批量生成和任务状态落点 +- `自定义生成` 有 Prompt 规则、定时任务、Scheduler 和幂等派发设计 +- `文章优化` 有优化任务、版本表、保存结果与回看路径 +- `媒体管理` 有插件执行端、插件会话、绑定回调、发布回调和状态同步 +- `品牌词库` 有关键词、问题、问题版本、竞品库与追踪关联 +- `知识库` 有分组、多来源解析策略、学习任务和对象存储落点 +- `数据追踪` 有明确采集单元、聚合表、缓存策略和查询口径 +- 所有长任务都有 `task_records`、SSE 事件和轮询兜底 +- 所有高风险操作、配置变更、额度变更、发布失败补偿都有审计记录 + +### 13.13 面向 5 万并发与简单运维的运行策略 + +#### 13.13.1 Gateway 水平扩展与负载均衡 + +- `gateway` 必须保持无状态,允许多实例水平扩展 +- 线上入口通过 `Ingress / Nginx` 做 L7 负载均衡,前端无需感知后端实例 +- Kubernetes 建议为 `gateway` 配置 HPA,核心观测指标为:`CPU`、`RPS`、`active_connections` +- `gateway` 到 `tenant-api` / `platform-api` 之间通过集群内部 Service 直连,V1 不引入额外服务网格 +- `gateway` 只允许承担统一入口职责,不允许沉淀业务编排、任务控制、数据聚合与数据库业务写入 + +#### 13.13.2 Scheduler 故障恢复策略 + +- V1 默认采用 `单活 scheduler + 健康检查 + 自动重启` +- `scheduler` 重启后必须重新扫描 `next_run_at <= now()` 的计划任务,并按幂等键自动追回漏派发任务 +- 若计划任务已超出允许追赶窗口,则记录为 `skipped/expired` 并写入审计日志 +- 派发状态需保留 `lease_owner`、`dispatch_lease_until`、`last_dispatch_at`,便于故障排查和追赶 + +#### 13.13.3 PostgreSQL 分区、索引与归档 + +- `audit_logs`、`config_change_logs`、`quota_change_logs`、`risk_action_logs` 按月分区 +- `question_monitor_runs`、`question_monitor_parse_results`、`question_model_*`、`keyword_model_*` 按日期分区 +- `articles` 至少提供 `(tenant_id, created_at desc)`、`(tenant_id, generate_status, publish_status, created_at desc)` 复合索引 +- 原始回答正文保留在对象存储 `30~90 天`,解析结果保留 `90 天`,聚合表保留 `180 天+` +- V1 先使用单 PostgreSQL 主实例;若读压升高,优先增加只读副本而不是立即拆库 + +#### 13.13.4 限流、队列保护与熔断 + +- `gateway` 层增加基于 Redis 的租户级令牌桶限流,维度为 `tenant_id + route_group` +- `tenant-api` 在任务提交前校验:套餐额度、租户当日提交量、当前排队深度、模型通道状态 +- `worker-generate` 对下游 LLM 调用统一使用 `context.WithTimeout(30s)`,并以 `semaphore` 控制单 consumer 组最大并发,V1 默认建议 `50` +- 交互式生成、批量生成、监控采集使用独立令牌桶;`worker-analytics` 默认只占用小比例低优先级通道,不能反压用户实时生成 +- `worker-generate` 与 `worker-analytics` 的上游调用按 `3s / 10s / 30s` 做有限退避重试;熔断打开后只允许少量探测请求恢复 +- `worker-knowledge` 的 Embedding 请求按 chunk 批量提交,建议单次 `16 ~ 32` 个 chunk,失败时自动拆批重试 +- RabbitMQ 为生成队列配置最大长度和溢出策略,超过阈值时拒绝低优先级批量任务 +- 平台端的强制重试、强制补量、限流豁免必须写审计日志 + +#### 13.13.5 Redis 缓存与对象存储分发策略 + +- 工作台聚合、趋势图、高频问题、引用排行等热点查询使用 `TTL + 抖动 + 异步刷新` +- 对不存在的数据维度使用空值缓存,避免缓存穿透 +- 高热点品牌数据在凌晨批量预热,避免白天冷启动击穿数据库 +- 公有云部署推荐 `OSS + CDN`;私有化部署推荐 `MinIO + Nginx / CDN` +- 图片、封面、知识库原文件通过带签名的对象存储 URL 输出,走 CDN 分发 +- 已删除知识文档的对象文件按 `T+7` 清理,原始模型回答按监控策略保留 `30 ~ 90 天` +- 发布请求/响应原始 payload 建议按审计需求保留 `30 ~ 90 天`,并由 GC 任务定期扫描孤儿文件 + +#### 13.13.6 配置管理与动态加载 + +- V1 配置层级采用:`base YAML -> 环境变量覆盖 -> 动态配置覆盖` +- 动态配置仅用于可热更新项:限流阈值、功能开关、模型通道权重、adapter 开关、轮询/SSE 策略 +- 结构性配置如数据库 DSN、MQ 地址、端口等仍通过重启生效,避免隐藏复杂度 +- 所有动态配置变更进入 `config_change_logs` + +#### 13.13.7 Sponge 使用原则 + +- `sponge` 仅作为脚手架初始化工具使用,不采用其默认的业务 `handler/service/dao` 全量代码生成 +- 如需复用少量样板代码,生成代码也必须按本文分层映射:`handler -> transport`、`service/logic -> app`、`dao -> repository`、`domain` 由业务手工维护 +- `tenant` 与 `platform` 的双域拆分优先于代码生成习惯,架构设计始终先于框架约束 + +#### 13.13.8 Migration 与 Schema 变更策略 + +- V1 推荐统一使用 `golang-migrate` 管理 Schema 版本,目录固定为 `server/migrations/` +- 命名规则建议:`YYYYMMDDHHMMSS_description.up.sql` 与对应 `down.sql` +- 线上 DDL 采用 expand / migrate / contract 三阶段,避免单次发布中直接删列、改类型或重建大索引 +- 分区表、索引和长耗时 DDL 必须走预发演练;高风险变更在低峰窗口执行并保留回滚脚本 + +#### 13.13.9 租户隔离与关键事务边界 + +- Repository 层必须提供 `TenantScope(ctx)` 或等价封装,租户侧查询默认自动注入 `tenant_id` +- 平台侧跨租户查询只允许出现在 `platform/*` 域;禁止在租户域直接手写绕过租户条件的 SQL +- CI 应增加静态检查或代码评审规则,重点扫描缺少 `tenant_id` 过滤的查询 +- 关键事务边界至少包括:任务提交预扣额度、Worker 成功确认额度、Worker 失败退还额度、插件回调幂等落库 + +#### 13.13.10 错误码、日志与链路追踪 + +- 所有进程统一使用结构化 JSON 日志,建议使用 `zap` +- `gateway` 为入口请求生成 `request_id`;若上游已带可信 `request_id` 则复用,并同时补充内部 `trace_id` +- API -> MQ -> Worker 全链路透传 `request_id`、`trace_id`、`tenant_id`、`task_batch_id` +- RabbitMQ 消息头必须包含上述追踪字段,Worker 落日志时原样打印 +- V1 最低要求是“统一错误码 + 结构化日志 + `request_id` 贯通”;V1.5 再引入 OpenTelemetry / Jaeger +- 所有告警规则统一绑定 `service`、`queue`、`tenant_scope`、`error_code` 标签,便于聚合排查 + +#### 13.13.11 本地开发、归档与运维模板 + +- 本地开发默认模式为“中间件容器化,Go 服务本地运行”,避免 `docker compose up` 同时拉起全部业务进程 +- 提供统一的 `make dev-init`、`make dev-api`、`make dev-workers` 或等价任务入口,降低新人启动门槛 +- 4 类 Worker 保持独立二进制,但共享同一套容器基座、健康检查协议、日志格式和 Helm 模板 +- 审计表与监控表分区需配套归档脚本或定时作业,按月导出历史分区并清理超期数据 +- 对象存储 GC、分区归档、死信队列巡检建议统一纳入 `scheduler` 的低频运维作业或独立运维 CronJob + +## 14. 最终建议 + +如果当前目标是尽快落地并保持后续可扩展,最推荐的方案是: + +- 前端采用双应用:`apps/admin-web` 与 `apps/ops-admin-web` +- V1 同步建设执行端:`apps/browser-extension` +- 共享能力全部沉淀到 `packages/*` +- 后端采用 `gateway + tenant-api + platform-api + scheduler + 多 Worker` 结构 +- 统一使用 `PostgreSQL + Redis + RabbitMQ + Qdrant + 对象存储` +- 权限严格按 `platform_role` 与 `tenant_role` 分层 +- 高风险操作统一纳入审计 + +这个方案的优点是: + +- 前端边界清晰 +- 后端复杂度可控 +- 权限模型不混乱 +- 既适合 V1,也方便未来演进到更细的服务拆分 diff --git a/docs/geo-platform-prd-v1.md b/docs/geo-platform-prd-v1.md new file mode 100644 index 0000000..f39bb21 --- /dev/null +++ b/docs/geo-platform-prd-v1.md @@ -0,0 +1,1078 @@ +# GEO 内容运营平台 PRD V1 + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档名称 | GEO 内容运营平台 PRD V1 | +| 文档版本 | V1.0 | +| 文档状态 | 待评审 | +| 创建日期 | 2026-03-25 | +| 适用阶段 | V1 立项 / 设计 / 开发评审 | +| 关联文档 | `docs/media-binding-and-publish-v1.md` | + +### 1.1 文档目标 + +本文档用于明确 GEO 内容运营平台 V1 的产品目标、范围边界、核心流程、页面功能、状态规则与验收标准,作为产品、设计、前后端、测试共同评审和执行的依据。 + +### 1.2 版本说明 + +- V1 聚焦打通内容生产、品牌知识沉淀、媒体账号绑定、手动发布、基础数据回看五条主链路。 +- V1 不追求复杂的自动化编排、深度 BI、完整商业化计费和高阶团队协作。 + +## 2. 项目背景 + +### 2.1 背景说明 + +随着搜索结果页、AI 问答、内容推荐平台对结构化内容和专业内容的权重提升,品牌和运营团队需要围绕 GEO 场景持续生产高质量内容,并将内容分发到不同媒体平台,形成品牌曝光、搜索覆盖和 AI 可引用资产。 + +当前常见工作方式存在以下问题: + +- 内容创作高度依赖人工,产出效率低,难以规模化。 +- 内容质量不稳定,不同编辑产出的结构、语气、品牌表达不一致。 +- 品牌资料、产品信息、常见问题分散在文档和网页中,难以复用。 +- 多平台发布依赖人工登录和复制粘贴,容易出错,效率低。 +- 发布后缺少统一的数据回看与效果追踪,无法持续优化。 + +### 2.2 目标用户 + +- 品牌运营团队 +- 内容编辑团队 +- 数字营销团队 +- 需要进行品牌内容分发的中小企业或工作室 + +### 2.3 核心价值 + +- 降低内容生产门槛,提升文章生成效率。 +- 基于品牌和知识库统一内容上下文,提升文章一致性。 +- 将“生成”和“发布”串联成一条可执行链路。 +- 沉淀平台账号、品牌资料、问题集和知识文档,形成长期资产。 +- 支持基础 GEO 数据回看,为后续优化提供依据。 + +## 3. 产品目标与边界 + +### 3.1 业务目标 + +- 在 V1 完成从内容生成到手动发布的最小闭环。 +- 建立品牌与知识资产沉淀能力,为后续内容生成提供输入。 +- 打通至少 1 个媒体平台的绑定与发布能力。 + +### 3.2 用户目标 + +- 用户可以在平台上快速生成指定类型的内容。 +- 用户可以维护品牌关键词、问题集、知识素材,提升内容质量。 +- 用户可以绑定媒体账号并从后台发起发布。 +- 用户可以查看文章状态、发布结果和基础数据概览。 + +### 3.3 成功指标 + +- 文章生成成功率 >= 90% +- 平台账号绑定成功率 >= 80% +- 单平台手动发布成功率 >= 80% +- 用户首次完成内容发布的时间 <= 30 分钟 +- 品牌建档完成率 >= 60% + +### 3.4 V1 范围 + +V1 包含: + +- 工作台 +- 模板创作 +- 自定义生成 +- 文章优化基础管理 +- 媒体管理 +- 品牌词库 +- 数据详情基础看板 +- 知识库基础管理 +- 套餐额度展示 + +V1 不包含: + +- 云端自动代发 +- 自动定时发布到第三方媒体 +- 团队成员管理与复杂权限 +- 完整商业化计费系统 +- 多维度 BI 自定义报表 +- 自动采集全网竞品情报 + +## 4. 用户角色与权限 + +### 4.1 角色定义 + +#### 4.1.1 管理员 + +- 管理平台配置 +- 查看并管理全部文章、品牌、知识库、媒体账号 +- 可执行发布、删除、重试等高权限操作 + +#### 4.1.2 运营编辑 + +- 创建和编辑文章 +- 创建优化任务 +- 维护品牌资料与知识库 +- 绑定媒体账号并发起发布 + +#### 4.1.3 只读成员 + +- 查看工作台、文章列表、数据详情 +- 不允许生成、发布、删除或修改品牌资产 + +### 4.2 权限原则 + +- 数据按当前账号隔离。 +- 删除、发布、重新绑定等操作仅管理员和运营编辑可用。 +- 平台账号敏感信息不在服务端保存登录态。 + +## 5. 核心业务流程 + +### 5.1 主流程 + +1. 用户创建品牌或选择已有品牌。 +2. 用户维护品牌关键词、问题集、知识文档。 +3. 用户选择模板或使用自定义 Prompt 发起文章生成。 +4. 系统生成文章草稿并保存到文章列表。 +5. 用户可对草稿进行预览、复制、优化或删除。 +6. 用户在媒体管理中绑定目标平台账号。 +7. 用户从文章列表中对指定文章发起手动发布。 +8. 浏览器插件执行真实发布动作并回传结果。 +9. 系统保存发布记录并在工作台与数据详情页展示结果。 + +### 5.2 模板创作流程 + +1. 用户进入模板创作页。 +2. 用户点击“选择模板创作”。 +3. 用户选择模板类型,如 Top X、产品评测、研究报告。 +4. 用户填写模板所需字段。 +5. 用户选择立即生成或加入批量任务。 +6. 系统创建生成任务并返回文章记录。 + +### 5.3 自定义生成流程 + +1. 用户进入自定义生成页。 +2. 用户选择 Prompt 规则、品牌、媒体平台等上下文信息。 +3. 用户发起即时任务或配置定时任务。 +4. 系统根据规则生成文章并保存结果。 + +### 5.4 媒体绑定流程 + +1. 用户进入媒体管理页。 +2. 用户选择目标平台,点击“去绑定”。 +3. 系统通过浏览器插件打开平台登录页。 +4. 用户完成平台登录。 +5. 插件识别账号信息并回传后台。 +6. 系统保存绑定关系并更新连接状态。 + +### 5.5 手动发布流程 + +1. 用户在文章列表或工作台点击“发布内容”。 +2. 系统校验目标平台账号是否已绑定且状态正常。 +3. 前端调用插件执行图片上传和文章发布。 +4. 插件回传发布结果。 +5. 系统记录发布状态、外部文章 ID、错误信息。 + +## 6. 信息架构 + +### 6.1 一级导航 + +- 工作台 +- 文章创作 +- 品牌管理 +- 数据追踪 +- 内容管理 + +### 6.2 二级导航 + +#### 6.2.1 文章创作 + +- 模板创作 +- 自定义生成 +- 文章优化 +- 媒体管理 + +#### 6.2.2 品牌管理 + +- 品牌词库 + +#### 6.2.3 数据追踪 + +- 数据详情 + +#### 6.2.4 内容管理 + +- 知识库 + +## 7. 功能清单与优先级 + +| 模块 | 功能点 | 优先级 | +| --- | --- | --- | +| 工作台 | 模板入口、数据总览、最近生成、快捷操作、额度展示 | P0 | +| 模板创作 | 模板列表、选择模板、单次生成、批量生成、文章列表筛选 | P0 | +| 自定义生成 | 即时任务、定时任务、Prompt 规则、生成记录管理 | P0 | +| 文章优化 | 导入文章、新建优化任务、优化列表、待处理任务入口 | P1 | +| 媒体管理 | 平台列表、账号绑定、连接状态、发布入口、过滤 | P0 | +| 品牌词库 | 品牌卡片、关键词、问题集、竞品库基础结构 | P0 | +| 数据详情 | 基础趋势图、高频问题、平台引用排行 | P1 | +| 知识库 | 分组、上传内容、列表管理、AI 学习状态 | P0 | +| 套餐额度 | 免费版展示、额度进度、重置时间 | P1 | + +## 8. 模块详细需求 + +## 8.1 工作台 + +### 8.1.1 页面目标 + +为用户提供平台概况、内容创作快捷入口和最近任务状态总览。 + +### 8.1.2 页面结构 + +页面包含以下区域: + +- 顶部模板类型卡片区 +- 右侧数据总览区 +- 最近生成列表区 +- 左下角套餐额度区 + +### 8.1.3 功能说明 + +#### A. 模板类型卡片 + +展示当前支持的文章模板类型: + +- Top X 文章 +- 产品评测文章 +- 研究报告文章 +- 模板持续扩展中 + +每张卡片包含: + +- 模板名称 +- 模板说明 +- 按钮“立即生成” + +点击“立即生成”后,跳转至对应的模板创建页或打开模板选择弹窗。 + +#### B. 数据总览 + +展示指标: + +- 生成数 +- 发布数 +- 品牌数 +- 媒体平台数 + +口径说明: + +- 生成数:当前账号已生成的文章总数 +- 发布数:已成功发布的记录数 +- 品牌数:当前账号下创建的品牌数 +- 媒体平台数:已绑定或支持的平台总数,最终口径以实现为准,需在开发前统一 + +#### C. 最近生成列表 + +字段包括: + +- 文章标题 +- 模板类型 +- 生成状态 +- 发布状态 +- 字数 +- 创建时间 +- 生成来源标签 +- 操作 + +操作按钮包括: + +- 发布 +- 优化 +- 预览 +- 复制 +- 删除 + +#### D. 套餐额度 + +展示: + +- 当前版本,如免费版 +- 升级入口 +- 文章生成配额 +- 配额重置时间 + +### 8.1.4 状态规则 + +生成状态: + +- 草稿 +- 生成中 +- 已完成 +- 生成失败 + +发布状态: + +- 未发布 +- 发布中 +- 发布成功 +- 发布失败 +- 待审核 + +### 8.1.5 验收标准 + +- 页面首次加载时可展示模板卡片、数据总览与最近生成列表。 +- 列表操作按钮按状态正确展示。 +- 点击模板入口可正常跳转或打开模板选择。 +- 额度区展示当前套餐和剩余额度。 + +## 8.2 文章创作 + +## 8.2.1 模板创作 + +### 页面目标 + +帮助用户基于预设模板快速生产结构化 GEO 文章。 + +### 页面结构 + +- 顶部标题与操作区 +- 筛选区 +- 文章列表 +- 选择模板弹窗 + +### 筛选项 + +- 模板类型 +- 发布状态 +- 生成时间 +- 生成状态 +- 标题关键字 + +### 操作区 + +- 批量生成文章 +- 选择模板创作 +- 重置筛选 + +### 文章列表字段 + +- 文章标题 +- 模板类型 +- 生成状态 +- 发布状态 +- 字数 +- 创建时间 +- 操作 + +### 模板类型 + +V1 支持: + +- Top X 文章 +- 产品评测文章 +- 研究报告文章 + +### 模板输入字段 + +不同模板字段可不同,V1 建议统一抽象为: + +- 品牌 +- 产品名 / 主题 +- 目标关键词 +- 文章语气 +- 目标平台 +- 字数要求 +- 补充说明 + +### 生成方式 + +- 单次生成 +- 批量生成 + +### 业务规则 + +- 生成前需校验额度。 +- 生成成功后落一篇新文章记录。 +- 批量生成以任务形式存在,可在列表中查看状态。 + +### 验收标准 + +- 可通过弹窗选择模板并进入创建流程。 +- 至少支持 1 个模板打通完整生成链路。 +- 列表筛选、查看、删除功能可用。 + +## 8.2.2 自定义生成 + +### 页面目标 + +支持用户基于自定义 Prompt 规则灵活生成文章,覆盖标准模板之外的内容场景。 + +### 页面结构 + +- 顶部快速入口卡片 +- Tab 区 +- 筛选区 +- 列表区 + +### 顶部卡片 + +- 即时生成内容 +- 定时计划生成 + +点击卡片进入对应任务创建流程。 + +### Tabs + +- 生成文章 +- 即时任务 +- 定时任务 +- Prompt 规则 + +### 筛选项 + +- Prompt +- 定时任务 +- 发布状态 +- 标题 +- 生成状态 +- 生成时间 + +### 列表字段 + +- 文章标题 +- 规则名称 +- Prompt +- 发布平台 +- 生成状态 +- 发布状态 +- 字数 +- 操作 + +### 功能点 + +#### A. Prompt 规则管理 + +支持创建和维护规则,规则字段包括: + +- 规则名称 +- Prompt 内容 +- 适用场景 +- 默认语气 +- 默认字数 +- 是否启用 + +#### B. 即时任务 + +用户填写参数后立即生成文章。 + +#### C. 定时任务 + +用户配置执行频率、起止时间、目标平台与规则。 + +V1 说明: + +- 可支持“定时生成文章” +- 不要求“自动定时发布到媒体平台” +- 生成和发布为两条独立链路 + +### 验收标准 + +- 用户可创建 Prompt 规则。 +- 用户可发起即时生成任务。 +- 用户可查看定时任务记录和生成结果。 + +## 8.2.3 文章优化 + +### 页面目标 + +为已生成或导入的文章提供二次加工能力,提升内容可读性、结构完整性和平台适配度。 + +### 页面结构 + +- 操作区 +- 筛选区 +- 优化文章列表 + +### 操作入口 + +- 导入已生成文章 +- 新建文章优化 +- 未完成优化任务 + +### 筛选项 + +- 发布状态 +- 生成时间 +- 生成状态 +- 搜索文章 + +### 列表字段 + +- 作品标题/来源 +- 字数 +- 发布状态 +- 最后修改时间 +- 操作 + +### 功能点 + +- 选择已有文章创建优化任务 +- 选择优化方向,如改写、扩写、精简、SEO/GEO 增强 +- 保存优化版本 +- 记录最后修改时间 + +### V1 边界 + +- V1 优先做“文章导入 + 优化任务管理 + 优化结果保存” +- 不要求完整的多人协作编辑器 +- 不要求复杂版本 diff 对比 + +### 验收标准 + +- 可从已生成文章中选择文章创建优化任务。 +- 优化后可保存新版本并返回列表。 +- 空状态、未完成任务入口正常展示。 + +## 8.2.4 媒体管理 + +### 页面目标 + +统一管理媒体平台账号绑定、状态查看和手动发布能力。 + +### 页面结构 + +- 媒体绑定流程引导区 +- 平台分类 Tabs +- 搜索与筛选区 +- 平台卡片列表 + +### 顶部流程引导 + +展示三步: + +1. 安装 GEO 插件 +2. 登录媒体账号 +3. 一键授权同步 + +### 平台分类 + +- UGC 媒体平台 +- 企业自建站 +- 第三方新闻源 + +V1 实际可先开放 UGC 平台,其他分类可先展示占位。 + +### 筛选条件 + +- 平台关键字搜索 +- 全部 +- 已连接 +- 未连接 + +### 平台卡片字段 + +- 平台名称 +- 平台类型 +- 绑定状态 +- 账号昵称 +- 外部平台 ID +- 运行状态 +- 操作按钮 + +### 操作按钮 + +- 去绑定 +- 重新检测 +- 发布内容 + +### 业务规则 + +- 绑定动作通过浏览器插件执行。 +- 服务端不保存第三方平台 cookie 或登录态。 +- 发布内容前必须校验账号状态为“运行正常”或“已连接”。 + +### 平台状态定义 + +- 未绑定 +- 绑定中 +- 已连接 +- 已失效 +- 需授权 + +### V1 平台策略 + +- 优先接入 1 个平台打通全链路 +- 推荐优先级:知乎 -> 头条号 -> 百家号 -> 网易号 + +### 验收标准 + +- 用户可看到平台列表和绑定状态。 +- 至少 1 个平台支持绑定成功。 +- 至少 1 个平台支持从后台触发手动发布。 +- 发布结果可回写为成功或失败。 + +## 8.3 品牌管理 + +## 8.3.1 品牌词库 + +### 页面目标 + +围绕品牌构建关键词、问题集和竞品信息,为内容生成提供统一上下文。 + +### 页面结构 + +- 品牌卡片区 +- 标签页切换区 +- 左侧关键词列表 +- 右侧问题集列表 + +### 品牌卡片 + +展示: + +- 品牌名称 +- 品牌描述 +- 新建品牌按钮 + +### Tab + +- 关键词 +- 竞品库 + +### 关键词模块 + +字段: + +- 关键词名称 +- 关键词数量 +- 新建关键词 + +### 问题集字段 + +- 问题内容 +- 创建时间 +- 操作 + +操作包括: + +- 查看数据 +- 删除 + +### 竞品库 + +V1 建议以基础结构落地: + +- 竞品名称 +- 官网 +- 竞品描述 +- 重点产品线 + +### 业务规则 + +- 一个账号可有多个品牌。 +- 一个品牌下可有多个关键词分类。 +- 一个关键词下可有多个问题集。 +- 生成文章时可选中品牌上下文,自动注入关键词和问题集。 + +### 验收标准 + +- 可创建品牌。 +- 可维护关键词和问题集。 +- 生成文章时可成功关联品牌信息。 + +## 8.4 数据追踪 + +## 8.4.1 数据详情 + +### 页面目标 + +向用户展示品牌或文章在 GEO 场景下的基础效果数据,支持趋势回看和高频问题分析。 + +### 页面结构 + +- 顶部指标卡 +- 趋势图 +- 高频问题区 +- 引用排行榜 + +### 顶部指标 + +V1 建议至少包含: + +- 提及率 +- 自评提及率 +- 自述推荐率 +- 正面提及率 + +具体口径需在研发前统一,避免统计口径不一致。 + +### 趋势图 + +按日期展示 GEO 指标变化趋势。 + +### 高频问题 + +展示近期高频触发的问题列表。 + +### 引用排行榜 + +按模型平台统计: + +- 模型平台名称 +- 引用文章数 +- 引用率 + +### V1 边界 + +- V1 可支持基础静态或任务计算后的数据展示 +- 不要求实时流式数据回收 +- 不要求复杂的筛选维度组合 + +### 验收标准 + +- 数据页可按时间展示基础趋势。 +- 可展示高频问题列表。 +- 可展示模型平台引用排行。 + +## 8.5 内容管理 + +## 8.5.1 知识库 + +### 页面目标 + +帮助用户上传和管理品牌相关资料,作为后续 AI 学习和文章生成的知识来源。 + +### 页面结构 + +- 新建内容按钮 +- 左侧分组树 +- 右侧列表 + +### 分组区 + +支持: + +- 默认分组 +- 新建分组 +- 展开/收起分组 + +### 列表字段 + +- 名称 +- 类型 +- 大小 +- AI 学习状态 +- 上传时间 +- 操作 + +### 新建内容方式 + +V1 支持以下来源: + +- 文档上传 +- 网站链接录入 +- 手动文本录入 + +### AI 学习状态 + +- 未学习 +- 学习中 +- 已完成 +- 失败 + +### 业务规则 + +- 知识条目上传后进入解析流程。 +- 解析成功后可被生成模块使用。 +- 删除知识条目后不再参与后续生成。 + +### 验收标准 + +- 用户可创建分组并上传内容。 +- 内容可展示在列表中并有学习状态。 +- 生成模块可选择是否使用知识库内容。 + +## 8.6 账号与套餐 + +### 页面目标 + +向用户展示当前版本、生成额度和升级入口。 + +### V1 范围 + +- 免费版展示 +- 文章生成额度进度 +- 重置时间 +- 升级按钮占位 + +### 业务规则 + +- 发起生成前校验额度。 +- 额度用尽后禁止继续生成,并提示升级或等待重置。 + +## 9. 通用交互规则 + +### 9.1 列表页通用规则 + +- 支持默认按创建时间倒序排列。 +- 筛选条件变更后需显式点击搜索或自动刷新,交互方案需统一。 +- 无数据时展示空状态图和引导文案。 + +### 9.2 删除规则 + +- 删除前弹出二次确认。 +- 已发布文章删除时需提示“仅删除系统记录,不影响第三方平台内容”。 + +### 9.3 长任务处理 + +- 文章生成、知识学习、发布等异步任务需展示状态。 +- 用户离开页面后,任务结果需可在列表中回看。 + +### 9.4 错误提示 + +- 错误提示应明确到动作级别,如“绑定失败”“发布失败”“知识解析失败”。 +- 对可重试操作提供重试入口。 + +## 10. 状态定义 + +### 10.1 文章状态 + +| 状态 | 说明 | +| --- | --- | +| draft | 草稿 | +| generating | 生成中 | +| completed | 已完成 | +| failed | 生成失败 | + +### 10.2 发布状态 + +| 状态 | 说明 | +| --- | --- | +| unpublished | 未发布 | +| publishing | 发布中 | +| success | 发布成功 | +| failed | 发布失败 | +| pending_review | 待审核 | + +### 10.3 平台账号状态 + +| 状态 | 说明 | +| --- | --- | +| pending | 等待绑定 | +| active | 已连接 | +| invalid | 已失效 | +| expired | 登录过期 | + +### 10.4 知识条目状态 + +| 状态 | 说明 | +| --- | --- | +| uploaded | 已上传 | +| parsing | 解析中 | +| learned | 学习完成 | +| failed | 处理失败 | + +## 11. 数据对象定义 + +### 11.1 用户与权限 + +- User +- Role +- Plan + +### 11.2 品牌资产 + +- Brand +- BrandKeyword +- BrandQuestion +- Competitor + +### 11.3 内容资产 + +- Article +- ArticleVersion +- GenerationTask +- ScheduleTask +- OptimizationTask + +### 11.4 媒体发布 + +- MediaPlatform +- PlatformAccount +- PublishRecord + +### 11.5 知识库 + +- KnowledgeGroup +- KnowledgeItem +- KnowledgeParseTask + +### 11.6 数据追踪 + +- AnalyticsSnapshot +- MentionRecord +- CitationRecord + +## 12. 业务规则 + +### 12.1 生成与发布解耦 + +- 文章生成成功不代表自动发布。 +- 发布操作必须由用户手动触发。 +- 定时任务仅负责生成,不负责自动代发。 + +### 12.2 平台账号安全 + +- 第三方平台登录态由本地浏览器插件持有。 +- 服务端仅保存平台 UID、昵称、头像、状态等非敏感资料。 + +### 12.3 品牌上下文注入 + +- 模板创作和自定义生成均可选择品牌。 +- 若选择品牌,则自动注入品牌关键词、问题集及相关知识内容。 + +### 12.4 套餐限制 + +- 免费版限制文章生成次数。 +- 后续可扩展平台绑定数、知识库容量、定时任务数量限制。 + +## 13. 非功能需求 + +### 13.1 性能 + +- 常规列表页首屏加载时间 <= 3 秒 +- 文章生成任务提交响应时间 <= 2 秒 +- 发布结果回写页面感知时间 <= 5 秒 + +### 13.2 稳定性 + +- 异步任务应具备失败记录能力。 +- 插件执行失败时,后台需保留失败原因。 +- 核心列表页需支持分页查询。 + +### 13.3 安全 + +- 所有接口必须鉴权。 +- 插件与页面通信需校验来源域名和会话信息。 +- 敏感数据不得明文落库。 + +### 13.4 可扩展性 + +- 媒体平台以 adapter 形式接入。 +- 大模型供应商需可替换。 +- 知识库解析与文章生成能力应支持异步任务扩展。 + +## 14. 埋点与数据统计 + +### 14.1 核心埋点事件 + +- 打开工作台 +- 点击模板卡片 +- 提交文章生成 +- 文章生成成功 +- 文章生成失败 +- 创建优化任务 +- 点击去绑定 +- 绑定成功 +- 绑定失败 +- 点击发布 +- 发布成功 +- 发布失败 +- 上传知识内容 +- 新建品牌 + +### 14.2 指标口径待确认项 + +- 发布数按“文章维度”还是“发布记录维度”统计 +- 媒体平台数按“支持平台”还是“已绑定平台”统计 +- 引用率与提及率的采集和计算口径 + +## 15. 外部依赖与技术边界 + +### 15.1 外部依赖 + +- 大模型服务 +- 文件存储服务 +- 浏览器插件 +- 第三方媒体平台 + +### 15.2 技术边界 + +- 媒体绑定与发布能力优先基于浏览器插件实现。 +- V1 不采用服务端代持第三方平台登录态。 +- 自动发布、远程执行器和多端调度不纳入 V1。 + +## 16. 验收标准 + +### 16.1 工作台 + +- 可展示模板入口、数据总览、最近生成列表和额度信息。 + +### 16.2 内容生成 + +- 至少支持 1 个模板完整生成文章。 +- 支持自定义 Prompt 生成文章。 +- 文章生成结果可在列表中查看和管理。 + +### 16.3 品牌与知识库 + +- 可创建品牌并维护关键词、问题集。 +- 可上传至少一种知识内容并展示学习状态。 + +### 16.4 媒体管理 + +- 至少支持 1 个平台的账号绑定。 +- 至少支持 1 个平台的后台手动发布。 +- 发布结果可回写系统。 + +### 16.5 数据追踪 + +- 可展示至少一组基础趋势图和引用排行数据。 + +## 17. 里程碑建议 + +### 17.1 第一阶段 + +- 登录与基础框架 +- 工作台 +- 模板创作 +- 品牌词库 +- 知识库 + +### 17.2 第二阶段 + +- 自定义生成 +- 媒体绑定 +- 单平台手动发布 +- 发布记录 + +### 17.3 第三阶段 + +- 文章优化 +- 数据详情 +- 批量生成与定时生成补齐 + +## 18. 风险与开放问题 + +### 18.1 主要风险 + +- 第三方平台页面结构变化会影响绑定和发布稳定性。 +- 不同平台的内容格式要求存在差异,可能导致发布失败。 +- 知识库内容质量直接影响生成效果。 +- GEO 指标采集口径不明确会影响数据可信度。 + +### 18.2 待确认问题 + +- V1 首发平台最终选型是知乎还是头条号。 +- 数据追踪页的数据来源是人工录入、定时采集还是模拟问答。 +- 套餐与额度是否需要在 V1 即接入支付能力。 +- 文章优化模块是否需要支持富文本在线编辑。 + +## 19. 附录 + +### 19.1 术语说明 + +- GEO:Generative Engine Optimization,面向生成式搜索和 AI 问答场景的内容优化。 +- UGC 平台:用户生成内容平台,如知乎、头条号、百家号等。 +- Prompt 规则:用于驱动大模型生成文章的模板化提示规则。 + +### 19.2 相关技术文档 + +- 媒体绑定与手动发布方案:`docs/media-binding-and-publish-v1.md` + diff --git a/docs/media-binding-and-publish-v1.md b/docs/media-binding-and-publish-v1.md new file mode 100644 index 0000000..69f7894 --- /dev/null +++ b/docs/media-binding-and-publish-v1.md @@ -0,0 +1,665 @@ +# V1 技术方案:绑定媒体账号与手动发送文章 + +> 口径说明:本文的插件执行流程、接口路径与会话模型已同步到主架构文档 [geo-platform-ops-admin-tech-architecture-v1.md](D:/project/geo/docs/geo-platform-ops-admin-tech-architecture-v1.md) 的 `§13.8 浏览器插件执行面闭环`、`§13.6.3 插件回调与内部接口`、`§13.6.4 统一错误响应与错误码`。若两份文档存在任何冲突,以主架构文档为准。 + +## 1. 文档目标 + +本文档用于落地一套与文擎 GEO 当前形态接近的系统能力,聚焦两个核心功能: + +- 绑定媒体账号 +- 手动发送文章到媒体平台 + +当前版本边界: + +- 支持平台账号绑定 +- 支持后台文章列表中手动点击发送 +- 支持插件执行真实发文 +- 不支持自动定时发送到媒体平台 +- 可支持后台定时生成文章,但生成与发送解耦 + +## 2. 产品边界 + +### 2.1 当前 V1 包含 + +- Web 管理后台 +- 媒体账号绑定页 +- 文章列表与文章详情页 +- 发送按钮与发送结果展示 +- 浏览器插件作为本地执行器 +- 平台适配层 +- 发送记录与状态记录 + +### 2.2 当前 V1 不包含 + +- 自动定时发送 +- 云端代发 +- 平台 cookie 或登录票据上传到服务端 +- 复杂运营数据回流 +- 多终端执行器调度 + +## 3. 总体架构 + +```mermaid +flowchart LR + A["Web 前端"] --> B["业务后端 API"] + A <---> C["浏览器插件"] + C --> D["平台适配层"] + D --> E["头条 / 百家 / 知乎 / 网易等平台"] + B --> F["PostgreSQL"] + B --> G["对象存储"] + C --> H["/api/callback/plugin/*"] + H --> B +``` + +## 4. 角色与职责 + +### 4.1 Web 前端 + +职责: + +- 展示媒体账号绑定状态 +- 展示文章列表和发送入口 +- 调用后端读取文章详情、创建 `plugin_session`、展示即时执行结果 +- 调用插件执行绑定与发文动作 + +前端不负责: + +- 直接登录媒体平台 +- 直接调用媒体平台发布接口 + +### 4.2 业务后端 + +职责: + +- 租户、文章、平台账号绑定关系管理 +- `plugin_session` 创建、校验与过期控制 +- 发送记录管理 +- 文章详情与资源存储 +- 结果审计与失败追踪 + +后端不负责: + +- 代替用户持有平台登录态 +- 直接模拟媒体平台发文 + +### 4.3 浏览器插件 + +职责: + +- 打开平台官网登录页 +- 检测平台登录状态 +- 获取当前绑定账号信息 +- 上传图片 +- 调用平台接口或执行页面内发布逻辑 +- 将真实执行结果双通道返回:即时回传 Web + 回调后端可靠落库 + +### 4.4 平台适配层 + +职责: + +- 每个平台单独封装接入逻辑 +- 对上暴露统一接口 +- 对下适配各平台差异 + +## 5. 核心设计原则 + +- 生成与发送分离 +- 控制面与执行面分离 +- 网站负责业务状态,插件负责真实执行 +- 服务端不存储平台 cookie +- 每个平台使用独立 adapter + +## 6. 绑定媒体账号方案 + +### 6.1 业务目标 + +用户在后台点击“去绑定”后,前端先创建一次性 `plugin_session`。插件打开对应平台官网登录页。用户完成登录后,插件识别账号信息并即时回传前端,同时调用回调 API 持久化绑定关系。 + +### 6.2 绑定流程 + +```mermaid +sequenceDiagram + participant U as 用户 + participant W as Web前端 + participant P as 插件 + participant M as 媒体平台 + participant B as 后端 + + U->>W: 点击去绑定 + W->>B: 创建 plugin_session(bind) + B-->>W: pluginSessionId + sessionToken + W->>P: openBindPage(platformId, loginUrl, ctx) + P->>M: 打开登录页 + loop 轮询检测 + P->>M: checkLogin / getUserInfo + M-->>P: 未登录或已登录账号信息 + end + P-->>W: bindingDetected(platformId, platformUid, nickname, avatar) + P->>B: POST /api/callback/plugin/bind + B-->>P: 持久化成功 + B-->>W: SSE / 轮询可见最新状态 +``` + +### 6.3 绑定执行细节 + +1. 前端先向后端申请 `plugin_session` +2. 后端返回 `pluginSessionId + sessionToken + expireAt` +3. 前端发起插件调用 `openBindPage` +4. 插件创建平台官网登录标签页 +5. 插件定时轮询平台账号状态 +6. 插件检测到 `platformUid` 后,先向网页回传绑定事件用于即时反馈 +7. 插件再调用 `/api/callback/plugin/bind` 可靠落库 +8. 后端写入 `platform_accounts` 并更新页面状态 + +### 6.4 绑定状态定义 + +- `active`:绑定有效 +- `invalid`:账号失效或检测失败 +- `expired`:账号登录态过期 +- `pending`:等待绑定结果 + +## 7. 手动发送文章方案 + +### 7.1 业务目标 + +用户在文章列表页点击发送按钮后,前端先创建一次性 `plugin_session`,再由插件读取文章内容并执行真实发文。发布结果即时回传页面,同时通过回调接口可靠写入后台。 + +### 7.2 发送流程 + +```mermaid +sequenceDiagram + participant U as 用户 + participant W as Web前端 + participant B as 后端 + participant P as 插件 + participant M as 媒体平台 + + U->>W: 点击发送按钮 + W->>B: 创建 plugin_session(publish) + B-->>W: pluginSessionId + sessionToken + W->>B: 获取文章详情 + B-->>W: 返回标题/正文/封面/图片 + W->>P: publishArticle(payload, ctx) + P->>P: 检查绑定账号与登录状态 + P->>M: 上传图片 + P->>M: 发布文章 + M-->>P: 返回 articleId / status + P-->>W: 发布结果 + P->>B: POST /api/callback/plugin/publish + B-->>P: 持久化成功 + B-->>W: SSE / 轮询更新页面状态 +``` + +### 7.3 发送执行细节 + +1. 前端先读取插件状态与最低兼容版本,不满足时直接阻断按钮 +2. 前端从后端创建 `plugin_session` +3. 前端从后端读取文章详情 +4. 前端构造发送请求并调用插件 +5. 插件检查对应平台账号是否已绑定且仍有效 +6. 插件上传封面与正文图片 +7. 插件按平台 adapter 执行发布 +8. 插件先向页面返回结果,再回调 `/api/callback/plugin/publish` +9. 页面通过 SSE 或轮询刷新显示最新状态 + +### 7.4 发送状态定义 + +- `running`:发送中 +- `success`:发送成功 +- `failed`:发送失败 +- `draft`:保存草稿成功 +- `pending_review`:平台审核中 + +## 8. 前后端接口设计 + +本节接口默认遵循主架构文档中的统一错误响应规范,失败时返回统一的 `code / message / detail / request_id` 结构。 + +### 8.1 绑定相关接口 + +#### `POST /api/tenant/media/plugin-sessions` + +请求体: + +```json +{ + "actionType": "bind", + "platformId": "zhihu" +} +``` + +响应: + +```json +{ + "pluginSessionId": "ps_xxx", + "sessionToken": "st_xxx", + "expireAt": "2026-03-26T12:00:00+08:00", + "minPluginVersion": "1.0.0" +} +``` + +#### `GET /api/tenant/media/platform-accounts` + +返回当前用户的媒体账号列表。 + +#### `POST /api/tenant/media/platform-accounts/{id}/check` + +触发一次重新检测,用于刷新登录有效性。前端应先创建 `plugin_session(actionType=check)`,再调用插件执行检查。 + +### 8.2 文章与发送接口 + +#### `GET /api/tenant/articles/{id}` + +返回文章详情: + +```json +{ + "id": "art_xxx", + "title": "文章标题", + "htmlContent": "

...

", + "markdownContent": "## ...", + "coverUrl": "https://xxx/cover.png", + "imageList": ["https://xxx/1.png"] +} +``` + +#### `GET /api/tenant/media/publish-records?articleId=art_xxx` + +查询指定文章的所有发送记录。 + +### 8.3 插件回调接口 + +#### `POST /api/callback/plugin/bind` + +请求体: + +```json +{ + "pluginSessionId": "ps_xxx", + "sessionToken": "st_xxx", + "platformId": "zhihu", + "platformUid": "708996890603040768", + "nickname": "CharmingGeeker", + "avatarUrl": "https://xxx/avatar.png", + "clientVersion": "1.0.0", + "metadata": {} +} +``` + +#### `POST /api/callback/plugin/publish` + +请求体: + +```json +{ + "pluginSessionId": "ps_xxx", + "sessionToken": "st_xxx", + "articleId": "art_xxx", + "platformAccountId": "pa_xxx", + "platformId": "zhihu", + "status": "success", + "clientVersion": "1.0.0", + "requestPayload": {}, + "responsePayload": {}, + "externalArticleId": "1234567890", + "errorMessage": null +} +``` + +#### `POST /api/callback/plugin/check` + +用于账号重检结果回写,负载与绑定接口类似,但语义是刷新已有 `platform_account` 状态。 + +## 9. 插件消息协议 + +### 9.1 插件可用性检测 + +页面加载时先执行: + +```ts +type PluginPingResp = { + installed: boolean; + version?: string; + capabilities?: Array<"bind" | "publish" | "checkStatus">; +}; +``` + +若未安装、被禁用或版本低于后端下发的 `minPluginVersion`,页面必须禁用绑定与发送按钮。 + +### 9.2 绑定媒体 + +共享上下文: + +```ts +type ExecContext = { + pluginSessionId: string; + sessionToken: string; + minPluginVersion: string; +}; +``` + +请求: + +```ts +type OpenBindPageReq = { + platformId: string; + loginUrl: string; + ctx: ExecContext; +}; +``` + +回调事件: + +```ts +type BindingDetectedEvent = { + pluginSessionId: string; + platformId: string; + platformUid: string; + nickname: string; + avatarUrl?: string; + metadata?: Record; +}; +``` + +### 9.3 发送文章 + +请求: + +```ts +type PublishArticleReq = { + articleId: string; + platformId: string; + platformAccountId: string; + title: string; + htmlContent: string; + markdownContent?: string; + coverUrl?: string; + imageList?: string[]; + publishType: "publish" | "draft"; + ctx: ExecContext; +}; +``` + +响应: + +```ts +type PublishArticleResp = { + success: boolean; + pluginSessionId: string; + platformId: string; + externalArticleId?: string; + status?: "draft" | "pending_review" | "published" | "failed"; + message?: string; +}; +``` + +## 10. 数据模型 + +### 10.1 平台账号表 + +```sql +create table platform_accounts ( + id varchar(64) primary key, + tenant_id varchar(64) not null, + user_id varchar(64) not null, + platform_id varchar(64) not null, + platform_uid varchar(128) not null, + nickname varchar(255) not null, + avatar_url text null, + status varchar(32) not null, + metadata_json jsonb null, + last_check_at timestamp null, + created_at timestamp not null, + updated_at timestamp not null +); +``` + +唯一约束建议: + +- `(tenant_id, user_id, platform_id, platform_uid)` + +### 10.2 插件会话表 + +```sql +create table plugin_sessions ( + id varchar(64) primary key, + tenant_id varchar(64) not null, + user_id varchar(64) not null, + action_type varchar(32) not null, + platform_id varchar(64) not null, + session_token varchar(128) not null, + client_version varchar(32) null, + expire_at timestamp not null, + status varchar(32) not null, + created_at timestamp not null, + updated_at timestamp not null +); +``` + +### 10.3 文章表 + +```sql +create table articles ( + id varchar(64) primary key, + tenant_id varchar(64) not null, + user_id varchar(64) not null, + title varchar(500) not null, + html_content text not null, + markdown_content text null, + cover_url text null, + image_list_json jsonb null, + status varchar(32) not null, + created_at timestamp not null, + updated_at timestamp not null +); +``` + +### 10.4 发送记录表 + +```sql +create table publish_records ( + id varchar(64) primary key, + tenant_id varchar(64) not null, + article_id varchar(64) not null, + platform_account_id varchar(64) not null, + platform_id varchar(64) not null, + status varchar(32) not null, + request_payload_json jsonb null, + response_payload_json jsonb null, + external_article_id varchar(128) null, + error_message text null, + created_at timestamp not null, + updated_at timestamp not null +); +``` + +## 11. 平台适配层设计 + +每个平台实现统一接口: + +```ts +interface PlatformAdapter { + platformId: string; + + checkLogin(ctx: ExecContext): Promise; + openBindPage(ctx: ExecContext): Promise; + uploadImage(input: UploadInput, ctx: ExecContext): Promise<{ url: string }>; + publish(input: PublishInput, ctx: ExecContext): Promise; + checkStatus(input: CheckStatusInput, ctx: ExecContext): Promise<{ status: string }>; +} +``` + +V1 推荐先做 1 个平台打通全链路,再扩展其他平台。 + +推荐顺序: + +1. 知乎 +2. 头条号 +3. 百家号 +4. 网易号 + +## 12. 插件实现要求 + +### 12.1 注入与通信 + +- 只注入本系统后台域名 +- 通过受控 `postMessage` 或 `runtime.sendMessage` 通信 +- 所有消息必须校验来源域名和会话信息 +- 必须支持 `plugin.ping`,返回安装状态、版本号和能力清单 +- 所有绑定 / 检测 / 发布动作都必须带上 `pluginSessionId + sessionToken` + +### 12.2 执行逻辑 + +- 打开登录页 +- 轮询登录状态 +- 上传图片 +- 发布文章 +- 返回结果 +- 绑定和发布结果必须“先即时回传页面,再回调后端可靠落库” +- 回调接口必须按 `plugin_session_id + callback_stage` 做幂等 + +### 12.3 V1 不要求 + +- 后台常驻拉任务 +- 自动定时发送 +- 云端远程控制 + +## 13. 安全要求 + +- 服务端不保存平台 cookie +- 插件与页面通信必须校验 `origin` +- 所有绑定与发送操作写审计日志 +- 后端保存文章与发送结果,便于追查 +- 平台 UID、昵称、头像可持久化,敏感票据只存在本地执行环境 +- 回调接口只信任 `session_token`,不信任浏览器 Cookie +- 插件只允许向受信后台域名发消息与回调 + +## 14. 错误处理 + +### 14.1 绑定失败 + +常见原因: + +- 用户未完成平台登录 +- 平台页面结构变化 +- 平台接口返回异常 +- 插件未安装或版本过低 + +处理方式: + +- 前端提示失败原因 +- 保持账号状态为 `pending` 或 `invalid` +- 支持重新检测 +- 提供插件安装或升级引导 + +### 14.2 发送失败 + +常见原因: + +- 平台账号失效 +- 图片上传失败 +- 平台接口改版 +- 平台风控或审核限制 +- 插件回调超时 + +处理方式: + +- 记录失败原因 +- 返回具体 message +- 发送记录保留原始结果 +- 回调失败进入补偿队列 + +## 15. 页面最小落地范围 + +### 15.1 媒体账号页 + +- 平台列表 +- 绑定状态 +- 去绑定按钮 +- 重新检测按钮 +- 插件安装 / 版本状态提示 + +### 15.2 文章列表页 + +- 文章标题 +- 文章状态 +- 发送按钮 +- 查看详情 +- 查看发送记录 + +### 15.3 发送记录页 + +- 平台 +- 发送时间 +- 发送状态 +- 平台文章 ID +- 错误原因 + +## 16. 开发顺序 + +### 阶段 1:业务后端 + +- 建表 +- `plugin_session` 接口 +- 文章接口 +- 插件回调接口 +- 发送记录查询接口 + +### 阶段 2:Web 前端 + +- 媒体管理页 +- 文章列表与发送按钮 +- 发送记录页 +- 插件可用性检测与降级提示 + +### 阶段 3:插件基础层 + +- 页面通信 +- `plugin.ping` +- 打开登录页 +- 回传绑定结果 +- 回调后端接口 + +### 阶段 4:首个平台 adapter + +- 先打通绑定 +- 再打通发文 +- 最后接状态查询 + +### 阶段 5:扩展更多平台 + +- 复制 adapter 结构 +- 按平台差异逐个接入 + +## 17. V1 验收标准 + +### 17.1 绑定媒体 + +- 可成功绑定至少 1 个平台 +- 可显示账号昵称和头像 +- 可执行重新检测 +- 插件能通过回调接口落库绑定关系 + +### 17.2 手动发送 + +- 从后台文章列表点击发送 +- 插件接收文章内容并发文 +- 平台返回文章 ID +- 插件能通过回调接口保存发送记录 +- 前端可看到发送成功或失败状态 + +## 18. 后续演进方向 + +V2 可扩展: + +- 多平台批量发送 +- 发送前 AI 优化 +- 发送后状态轮询 +- 草稿箱管理 + +V3 可扩展: + +- 本地 agent 常驻 +- 自动定时发送 +- 多执行器调度 +- 失败重试与补偿 diff --git a/docs/qdrant-rag-architecture-v1.md b/docs/qdrant-rag-architecture-v1.md new file mode 100644 index 0000000..dea6115 --- /dev/null +++ b/docs/qdrant-rag-architecture-v1.md @@ -0,0 +1,650 @@ +# GEO 平台 Qdrant 版 RAG 架构设计 V1 + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档名称 | GEO 平台 Qdrant 版 RAG 架构设计 V1 | +| 文档版本 | V1.0 | +| 文档状态 | 待评审 | +| 创建日期 | 2026-03-25 | +| 适用范围 | GEO 平台 V1 知识库与高精度 RAG 检索 | +| 关联文档 | `docs/geo-platform-tech-architecture-v1-50k.md` | +| 关联文档 | `docs/geo-platform-prd-v1.md` | + +## 2. 设计目标 + +本文档用于定义 GEO 平台在 V1 阶段的 RAG 技术方案,目标是: + +- 让知识库检索结果足够精准,支持品牌内容生成和问答增强。 +- 将事务型业务数据与向量检索能力解耦。 +- 在 5 万在线用户前提下,保证知识库查询链路稳定、可扩展。 +- 为后续多品牌、多租户、多知识源、多平台内容生成打好基础。 + +## 3. 设计结论 + +V1 的 RAG 主方案建议为: + +- `PostgreSQL` 负责知识元数据、权限、品牌、分组、任务状态 +- `Qdrant` 负责向量索引和向量检索 +- `对象存储` 负责原始文档和解析中间产物 +- `Worker` 负责解析、切片、Embedding、写入、重建索引 + +检索链路建议为: + +1. 查询预处理 +2. 过滤条件构造 +3. Qdrant dense / hybrid 检索 +4. rerank 精排 +5. 上下文拼装 +6. 提交给大模型生成 + +## 4. 为什么选 Qdrant + +### 4.1 业务需求决定 + +GEO 平台的 RAG 检索不是简单的“全文找相似段落”,而是带强业务约束的检索: + +- 只检索当前租户数据 +- 只检索当前品牌或选定品牌数据 +- 只检索指定知识分组或文档类型 +- 需要按更新时间、来源、文档权重做过滤或排序 +- 后续要支持 hybrid search 和 rerank + +因此检索系统必须同时擅长: + +- 高效向量召回 +- 高效 payload filter +- 后续高精度搜索演进 + +### 4.2 相比 pgvector 的优势 + +Qdrant 更适合做 V1 主方案,原因如下: + +- 向量检索从 PostgreSQL 主交易链路中解耦 +- metadata filter 能力更适合 RAG 场景 +- 后续扩容路径清晰 +- 更方便演进到 hybrid search、多向量和 rerank 方案 + +### 4.3 pgvector 的角色 + +`pgvector` 仍可作为轻量备选方案: + +- 数据量较小 +- 团队只有单数据库运维能力 +- 只是验证 MVP + +但如果目标是“RAG 要精准好用”,Qdrant 更适合作为长期主方案。 + +## 5. RAG 场景定义 + +### 5.1 主要使用场景 + +#### A. 文章生成增强 + +在生成品牌文章时,从品牌知识库中召回相关背景材料、产品说明、FAQ、历史内容和网页资料,为模型生成提供高质量上下文。 + +#### B. 品牌问答增强 + +当系统生成“问题集答案”“趋势分析”“竞品分析”类内容时,先检索品牌知识和文档片段,再交给模型组织答案。 + +#### C. 内容优化增强 + +对已有文章做优化时,检索品牌标准话术、产品卖点、平台规范、历史高质量文章片段。 + +### 5.2 不适合 V1 的场景 + +- 跨品牌全局开放检索 +- 实时抓取网页后秒级入库并立刻高质量检索 +- 十亿级向量规模 +- 强知识图谱推理 + +## 6. 数据分层设计 + +## 6.1 PostgreSQL 存储内容 + +PostgreSQL 中保存: + +- `knowledge_bases` +- `knowledge_groups` +- `knowledge_items` +- `knowledge_parse_tasks` +- `knowledge_chunks_meta` +- `brand` +- `tenant` +- `document_acl` +- `retrieval_logs` +- `rag_eval_cases` + +### PostgreSQL 的职责 + +- 保存业务主数据 +- 控制权限与租户隔离 +- 提供后台列表页查询 +- 保存 chunk 元数据和状态 +- 保存评测数据和命中日志 + +## 6.2 Qdrant 存储内容 + +Qdrant 中保存: + +- chunk 向量 +- chunk payload +- 文档级过滤字段 +- chunk 排序辅助字段 + +### Qdrant 的职责 + +- 相似度召回 +- filter + ANN 检索 +- hybrid search +- rerank 前候选集召回 + +## 6.3 对象存储内容 + +- 原始 PDF / DOCX / TXT / HTML +- 提取后的 Markdown / 纯文本 +- 清洗后的中间文件 + +## 7. Collection 设计 + +## 7.1 设计原则 + +V1 不建议每个品牌一个 collection,也不建议每个租户大量拆 collection。 + +推荐策略: + +- 按业务域建立少量 collection +- 通过 payload filter 做租户、品牌、分组隔离 +- 避免 collection 数量过多导致管理复杂 + +## 7.2 推荐 collection + +### collection 1: `kb_chunk_main` + +用途: + +- 存放知识库标准 chunk +- 支撑文章生成、优化、品牌问答等主要检索 + +### collection 2: `content_example_main` + +用途: + +- 存放历史优质文章片段 +- 支撑风格参考、结构参考、示例生成 + +V1 如果要尽量简单,也可以先只保留一个 `kb_chunk_main`。 + +## 7.3 向量字段建议 + +### 主向量 + +- `dense_vector` +- 维度按所选 embedding 模型决定 +- 距离建议优先 `Cosine` + +### 预留向量 + +后续可扩展: + +- `title_vector` +- `summary_vector` +- `sparse_vector` + +V1 可以先只落一个 dense vector。 + +## 8. Payload 设计 + +## 8.1 必须字段 + +每个 chunk 在 Qdrant 中建议至少带以下 payload: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `tenant_id` | keyword | 租户隔离 | +| `brand_id` | keyword | 品牌过滤 | +| `kb_id` | keyword | 知识库 ID | +| `group_id` | keyword | 分组过滤 | +| `item_id` | keyword | 文档 ID | +| `chunk_id` | keyword | chunk 唯一 ID | +| `doc_type` | keyword | 文档类型 | +| `source_type` | keyword | 来源类型,如上传文档/网页/手输 | +| `language` | keyword | 语言 | +| `status` | keyword | 是否可检索 | +| `priority` | integer | 文档优先级 | +| `published_at` | integer | 发布时间时间戳 | +| `updated_at` | integer | 更新时间时间戳 | +| `token_count` | integer | chunk token 数 | +| `title` | text/keyword | 文档标题 | +| `tags` | keyword[] | 标签 | + +## 8.2 推荐扩展字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `is_official_doc` | bool | 是否官方资料 | +| `is_product_doc` | bool | 是否产品文档 | +| `author` | keyword | 作者 | +| `source_url` | keyword | 来源链接 | +| `quality_score` | float | 文档质量分 | +| `freshness_score` | float | 时效性分 | +| `manual_boost` | float | 人工加权 | + +## 8.3 索引建议 + +Qdrant 中对高频 filter 字段建立 payload index,优先包括: + +- `tenant_id` +- `brand_id` +- `group_id` +- `doc_type` +- `status` +- `source_type` + +如果查询高频使用时间和标签,也可以增加: + +- `updated_at` +- `tags` + +## 9. Chunk 设计 + +## 9.1 核心原则 + +RAG 准确率很多时候不是输在向量库,而是输在 chunk 切分。 + +切分原则: + +- 按语义结构切,不按固定字数硬切 +- 保持 chunk 语义完整 +- 控制 chunk 粒度不要过大 +- 保留上下文关联信息 + +## 9.2 推荐切分策略 + +### 文档型内容 + +优先按以下结构切分: + +- 一级标题 +- 二级标题 +- 段落簇 +- 列表块 +- 表格说明块 + +### 网页型内容 + +优先清洗掉: + +- 导航 +- 页脚 +- 广告 +- 无意义重复区域 + +保留: + +- 标题 +- 摘要 +- 正文 +- FAQ +- 参数表 + +## 9.3 V1 chunk 参数建议 + +- 目标 token:`300 ~ 600` +- overlap:`50 ~ 100` +- 对 FAQ 场景可更短:`100 ~ 250` +- 对长报告场景可按标题段落聚合到 `500 ~ 800` + +### 说明 + +- 太短会导致上下文碎片化,召回噪音高 +- 太长会导致 embedding 表达不准,且浪费上下文窗口 + +## 9.4 特殊处理建议 + +### FAQ 文档 + +按 `问题 + 答案` 成对切 chunk,不要拆开。 + +### 产品参数文档 + +按“模块 + 参数块”切分,并保留型号、版本、发布时间。 + +### 品牌官网介绍 + +品牌介绍、品牌故事、产品卖点、售后说明应分开切分。 + +## 10. 入库流程设计 + +## 10.1 标准流程 + +1. 用户上传文档或录入网页。 +2. 文件进入对象存储。 +3. 创建解析任务。 +4. Worker 提取文本并清洗。 +5. 按规则切 chunk。 +6. 为每个 chunk 生成 embedding。 +7. chunk 写入 PostgreSQL 元数据表。 +8. 向量和 payload 写入 Qdrant。 +9. 更新知识条目状态为可用。 + +## 10.2 幂等要求 + +- 同一文档重复上传要可识别 +- chunk 重建时采用版本号或软删除策略 +- 文档更新后需支持全量替换对应 chunk + +## 10.3 文档版本策略 + +推荐方案: + +- `knowledge_item` 保存文档主记录 +- `knowledge_item_version` 保存版本记录 +- `knowledge_chunk_meta` 带 version 字段 +- Qdrant payload 中写入 `item_version` + +检索时仅查询当前生效版本。 + +## 11. 检索流程设计 + +## 11.1 标准检索链路 + +1. 接收用户查询或文章生成任务上下文 +2. 做 query rewrite +3. 构造 filter 条件 +4. 从 Qdrant 召回 topK 候选 chunk +5. 使用 reranker 精排 +6. 去重与多样性控制 +7. 拼接最终上下文 +8. 返回给大模型 + +## 11.2 查询预处理 + +建议包含: + +- 去除无意义词 +- 标准化品牌名、产品名、简称 +- 同义词扩展 +- 根据任务类型追加意图词 + +例如: + +- “帮我写某品牌数字时尚趋势文章” +- 可以扩展为: +- “数字时尚 品牌趋势 新趋势 消费者关注点 产品风格 设计趋势” + +## 11.3 filter 策略 + +RAG 结果想精准,必须优先加 filter,而不是裸搜。 + +推荐默认过滤条件: + +- `tenant_id = 当前租户` +- `status = active` + +可选过滤条件: + +- `brand_id in 当前品牌集合` +- `group_id in 指定知识分组` +- `doc_type in 允许文档类型` +- `updated_at > 某时间` + +## 11.4 召回策略 + +### V1 基础版 + +- dense retrieval +- topK = 30 ~ 50 + +### V1 推荐版 + +- dense retrieval + BM25 或 sparse retrieval +- hybrid merge +- rerank top 30 +- 最终输出 top 5 ~ 10 + +## 11.5 去重与多样性 + +召回后应做: + +- 同文档 chunk 去重 +- 相邻 chunk 合并 +- 标题级去重 +- 不同来源混排 + +防止最终上下文全部来自同一篇文档的连续片段。 + +## 12. rerank 设计 + +## 12.1 为什么必须 rerank + +向量召回解决的是“先找一批可能相关的候选”,不是最终最准排序。 + +如果不做 rerank,常见问题包括: + +- 语义相近但不回答问题 +- 命中了品牌,但不是当前主题 +- 召回结果太泛 + +## 12.2 V1 rerank 方案 + +推荐两级: + +### 方案 A:轻量版 + +- dense topK 召回 +- 用 cross-encoder reranker 排序 +- 取前 5 到 8 个 chunk + +### 方案 B:增强版 + +- dense + sparse hybrid +- rerank +- 基于来源权重、官方文档权重、时效权重做分数融合 + +## 12.3 打分建议 + +最终分数建议由以下部分组成: + +- semantic_score +- rerank_score +- quality_score +- freshness_score +- manual_boost + +可采用线性融合,V1 不必一开始做复杂学习排序。 + +## 13. 上下文拼装策略 + +## 13.1 拼装原则 + +- 少而准优于多而杂 +- 优先保留完整意义块 +- 优先官方和高质量来源 +- 记录引用来源,方便可解释 + +## 13.2 推荐拼装方式 + +每个返回 chunk 建议带: + +- 文档标题 +- 来源类型 +- 来源 URL +- chunk 正文 +- 更新时间 + +拼装给模型时,建议按主题分组,而不是简单拼接长文本。 + +## 13.3 token 控制 + +不同任务控制不同上下文预算: + +- 短文章生成:`1000 ~ 2000 tokens` +- 长文章生成:`2000 ~ 5000 tokens` +- 问答增强:`800 ~ 1500 tokens` + +## 14. 多租户与权限设计 + +## 14.1 隔离原则 + +所有检索必须带 `tenant_id` 过滤,不允许裸查询。 + +## 14.2 品牌隔离 + +如果一个用户一次只操作一个品牌,检索时默认加: + +- `brand_id = current_brand` + +如果是跨品牌分析,再显式放开。 + +## 14.3 文档权限 + +如果后续支持团队协作,可增加: + +- 可见范围 +- 部门范围 +- 角色范围 + +V1 可以先在 PostgreSQL 中控制可见性,再把最终允许检索的 payload 写入 Qdrant。 + +## 15. 性能设计 + +## 15.1 查询延迟目标 + +| 项目 | 目标 | +| --- | --- | +| query rewrite | < 50ms | +| Qdrant topK 检索 | < 100ms | +| rerank | < 200ms | +| 检索总耗时 | < 400ms | + +### 说明 + +以上是服务端目标,前端或模型生成耗时不包含在内。 + +## 15.2 并发策略 + +- 热门品牌查询走缓存 +- 高频 filter 字段建立 payload index +- rerank 单独走线程池或独立服务 +- query rewrite 和 rerank 限并发,避免放大成本 + +## 15.3 写入策略 + +- 文档上传与知识学习异步处理 +- 向量写入批量提交 +- 文档更新采用分批重建 + +## 16. 精准度评测体系 + +## 16.1 为什么必须做评测 + +RAG 不评测,精准度一定会失控。 + +V1 至少需要建立一套小型评测集。 + +## 16.2 评测集构成 + +每个品牌至少准备: + +- 20 个高频问题 +- 20 个内容生成主题 +- 10 个产品对比问题 +- 10 个品牌介绍问题 + +总计每品牌建议至少 50 条评测 query。 + +## 16.3 评测指标 + +推荐指标: + +- Recall@5 +- Recall@10 +- Precision@5 +- MRR +- NDCG +- Answer groundedness +- 人工满意度 + +## 16.4 人工评测维度 + +每条 query 至少看: + +- 是否命中正确文档 +- 是否命中正确片段 +- 是否出现品牌错配 +- 是否出现过期内容 +- 是否出现无关噪音 + +## 16.5 评测流程 + +1. 固定查询集 +2. 固定期望命中文档 +3. 跑召回与 rerank +4. 输出命中率报表 +5. 低分案例人工复盘 +6. 调整 chunk、payload、topK、rerank 策略 + +## 17. 监控与日志 + +## 17.1 监控指标 + +- Qdrant 查询延迟 +- Qdrant 查询 QPS +- payload filter 命中比例 +- topK 平均耗时 +- rerank 平均耗时 +- 检索空结果率 +- 检索后回答满意度 + +## 17.2 日志记录 + +每次检索建议记录: + +- query +- rewrite_query +- filter 条件 +- topK 召回结果 +- rerank 结果 +- 最终返回 chunk +- 生成任务 ID 或问答 ID + +方便后续做 badcase 分析。 + +## 18. 风险与边界 + +### 18.1 常见风险 + +- chunk 切分不合理,导致检索效果差 +- payload 设计不完整,导致过滤能力不足 +- 只做召回不做 rerank,结果不稳定 +- 知识库内容脏乱,导致“检索准但答案差” + +### 18.2 V1 边界 + +- V1 不要求做复杂多跳推理 +- V1 不要求做全自动 query rewrite 学习 +- V1 不要求做复杂学习排序 + +## 19. V1 最终建议 + +如果你的目标是“RAG 精准好用”,V1 建议采用这套最实用组合: + +- `PostgreSQL` 做业务主存储和知识元数据 +- `Qdrant` 做向量检索 +- `RabbitMQ` 做知识学习与检索相关任务队列 +- `对象存储` 做原始文档存储 +- `dense + filter + rerank` 做主检索链路 + +同时一定要配套以下机制: + +- 合理 chunk 切分 +- 完整 payload 字段 +- payload index +- rerank +- 评测集 +- badcase 回放 + +没有这些,单纯换向量库也不可能让 RAG 自动变得精准。 + diff --git a/docs/question-driven-monitoring-design-v1.md b/docs/question-driven-monitoring-design-v1.md new file mode 100644 index 0000000..207d362 --- /dev/null +++ b/docs/question-driven-monitoring-design-v1.md @@ -0,0 +1,494 @@ +# GEO 平台问题驱动的数据监控设计 V1 + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档名称 | GEO 平台问题驱动的数据监控设计 V1 | +| 文档版本 | V1.0 | +| 文档状态 | 待评审 | +| 创建日期 | 2026-03-25 | +| 适用范围 | 品牌词库、问题集、数据追踪页 | +| 关联文档 | `docs/geo-platform-prd-v1.md` | +| 关联文档 | `docs/geo-platform-tech-architecture-v1-50k.md` | + +## 2. 设计背景 + +当前数据监控页不是通用 BI,而是明确来源于: + +- 品牌 +- 关键词 +- 关键词下的问题集 +- 模型 +- 最近 30 天时间窗口 + +因此,数据监控的真实逻辑源头不是“文章”本身,也不是“关键词”本身,而是: + +- `品牌关键词下配置的问题` +- `系统按周期执行这些问题后的模型回答结果` + +这意味着整套监控系统必须按“问题驱动”来设计,否则后续会出现: + +- 指标口径不清 +- 历史数据与问题版本混淆 +- 任务量失控 +- 查询逻辑和数据模型对不上 + +## 3. 核心结论 + +V1 建议采用以下原则: + +1. `问题` 是监控数据的最小采集单元。 +2. `关键词` 只是问题集合的容器和聚合维度。 +3. `品牌监控页` 展示的是问题运行结果的聚合视图。 +4. `历史数据必须绑定问题版本`,不能因为改了问题文案就覆盖历史口径。 +5. `页面查询只查汇总表`,不扫描原始问答记录。 + +## 4. 页面逻辑拆解 + +结合当前页面,用户选择条件是: + +- 品牌 +- 关键词 +- 模型 +- 时间范围,最多近 30 天 + +页面展示内容包括: + +- 提及率 +- 首位提及率 +- 首选推荐率 +- 正面提及率 +- 日趋势折线 +- 高频问题 +- 引用排行榜 + +这些数据不应该直接从原始问答明细实时计算,而应该来自预聚合结果。 + +## 5. 数据采集逻辑 + +## 5.1 问题来源 + +问题来源于品牌词库中的关键词问题集。 + +例如: + +- 品牌:12 +- 关键词:数字时尚 +- 问题: + - 数字时尚品牌有哪些值得关注的新趋势 + - 如何选择适合自己的数字时尚单品 + - 数字时尚和传统时尚的主要区别是什么 + +这些问题是监控任务的输入,不是单纯展示内容。 + +## 5.2 采集单元 + +建议将一次监控采集定义为: + +- 一个问题 +- 一个模型 +- 一次执行时间 + +即: + +`1 question x 1 model x 1 run_time = 1 条原始监控记录` + +## 5.3 指标来源 + +每次模型回答返回后,系统进行解析,得到结构化结果: + +- 是否提及品牌 +- 是否首位提及品牌 +- 是否首选推荐品牌 +- 情感倾向是否正向 +- 是否引用系统文章 +- 引用了哪篇文章 + +然后再做日级汇总。 + +## 6. 推荐数据模型 + +## 6.1 主数据表 + +### `brand_keywords` + +- `id` +- `brand_id` +- `name` +- `status` +- `created_at` + +### `brand_questions` + +- `id` +- `brand_id` +- `keyword_id` +- `current_version_id` +- `status` +- `created_at` + +说明: + +- 一条问题属于一个品牌和一个关键词 +- 数据监控页中的“高频问题”和“问题集”都可以从这里关联出来 + +### `brand_question_versions` + +- `id` +- `question_id` +- `question_text` +- `question_hash` +- `version_no` +- `is_active` +- `created_at` + +说明: + +- 问题文案一旦修改,新增版本,不覆盖历史版本 +- 历史监控记录绑定到具体 `question_version_id` + +## 6.2 原始采集表 + +### `question_monitor_runs` + +- `id` +- `brand_id` +- `keyword_id` +- `question_id` +- `question_version_id` +- `model_id` +- `run_date` +- `run_at` +- `task_batch_id` +- `status` +- `raw_answer_storage_key` +- `created_at` + +说明: + +- 一次执行一条记录 +- 模型原始回答建议放对象存储,数据库只存引用路径和摘要字段 + +### `question_monitor_parse_results` + +- `id` +- `run_id` +- `mentioned_brand` +- `top1_mentioned` +- `first_recommended` +- `positive_mentioned` +- `citation_article_count` +- `citation_platform_count` +- `parser_version` +- `created_at` + +说明: + +- 解析结果是后续聚合的直接来源 + +## 6.3 汇总表 + +### `question_model_daily_metrics` + +粒度: + +- `brand_id + keyword_id + question_id + question_version_id + model_id + date` + +字段: + +- `ask_count` +- `mention_count` +- `top1_mention_count` +- `first_recommend_count` +- `positive_mention_count` +- `citation_count` +- `updated_at` + +用途: + +- 回看单个问题在每天的表现 +- 支撑高频问题排行和问题详情 + +### `keyword_model_daily_metrics` + +粒度: + +- `brand_id + keyword_id + model_id + date` + +字段: + +- `question_count` +- `ask_count` +- `mention_count` +- `top1_mention_count` +- `first_recommend_count` +- `positive_mention_count` +- `citation_count` +- `mention_rate` +- `top1_mention_rate` +- `first_recommend_rate` +- `positive_mention_rate` +- `updated_at` + +用途: + +- 直接支撑你截图里的 30 天趋势和顶部指标卡 + +### `keyword_model_top_questions_daily` + +粒度: + +- `brand_id + keyword_id + model_id + date + question_id` + +字段: + +- `question_text_snapshot` +- `ask_count` +- `mention_count` +- `score` + +用途: + +- 支撑右侧“高频问题” + +### `keyword_model_citation_rank_daily` + +粒度: + +- `brand_id + keyword_id + model_id + date + cited_article_id` + +字段: + +- `citation_count` +- `citation_rate` + +用途: + +- 支撑引用排行榜 + +## 7. 查询口径定义 + +## 7.1 顶部指标卡 + +以所选: + +- 品牌 +- 关键词 +- 模型 +- 时间范围 + +为过滤条件,从 `keyword_model_daily_metrics` 汇总得出: + +- 提及率 = `sum(mention_count) / sum(ask_count)` +- 首位提及率 = `sum(top1_mention_count) / sum(ask_count)` +- 首选推荐率 = `sum(first_recommend_count) / sum(ask_count)` +- 正面提及率 = `sum(positive_mention_count) / sum(ask_count)` + +## 7.2 趋势图 + +直接查询 `keyword_model_daily_metrics` 最近 30 天数据。 + +每个点只是一行日汇总,查询量很小。 + +## 7.3 高频问题 + +从 `keyword_model_top_questions_daily` 取最近 30 天累计 TopN。 + +## 7.4 引用排行榜 + +从 `keyword_model_citation_rank_daily` 取最近 30 天累计 TopN。 + +## 8. 任务调度设计 + +## 8.1 为什么要控制任务量 + +监控任务量不是由页面访问量决定,而是由: + +- 品牌数 +- 每个品牌下关键词数 +- 每个关键词下问题数 +- 模型数 +- 每天执行次数 + +共同决定。 + +## 8.2 数据量公式 + +每日原始采集量近似为: + +`品牌数 x 每品牌关键词数 x 每关键词问题数 x 模型数 x 每日执行次数` + +例如: + +### 轻量场景 + +- 50 个品牌 +- 每品牌 10 个关键词 +- 每关键词 10 个问题 +- 5 个模型 +- 每天 1 次 + +则: + +`50 x 10 x 10 x 5 x 1 = 25,000 条/天` + +### 中等场景 + +- 100 个品牌 +- 每品牌 20 个关键词 +- 每关键词 20 个问题 +- 5 个模型 +- 每天 2 次 + +则: + +`100 x 20 x 20 x 5 x 2 = 400,000 条/天` + +如果每条原始回答按 5KB 估算: + +- 每天约 2GB 原始文本 +- 30 天约 60GB 原始文本 + +因此: + +- 原始回答不建议长期放 PostgreSQL +- 建议放对象存储 +- PostgreSQL 只保存解析结果和汇总结果 + +## 8.3 V1 推荐执行频率 + +为了控制数据量,V1 建议: + +- 每个问题每天默认执行 `1 次` +- 重要品牌或付费用户可提升为 `2 次` +- 不建议 V1 做高频小时级轮询 + +## 8.4 去重策略 + +如果同一品牌下多个关键词出现完全相同的问题文案,建议: + +- 通过 `question_hash` 检测重复问题 +- 同一品牌、同一模型、同一天内同 hash 的问题尽量复用一次执行结果 + +但聚合归属仍可按映射关系分别记账。 + +这样可以显著减少任务量。 + +## 9. 版本与历史口径 + +## 9.1 为什么必须版本化 + +如果用户把问题: + +- “数字时尚品牌有哪些值得关注的新趋势” + +改成: + +- “2026 年数字时尚品牌有哪些值得关注的新趋势” + +那前后两者其实已经不是同一个监控口径。 + +如果直接覆盖旧问题,历史趋势就会失真。 + +## 9.2 推荐做法 + +- 问题修改时新建 `question_version` +- 新运行记录绑定到新版本 +- 历史数据仍归属旧版本 +- 页面默认展示当前版本问题的聚合数据 + +如果要做历史总览,可按 `question_id` 聚合,但需要在文档里说明“跨版本聚合”。 + +## 10. 页面查询策略 + +## 10.1 页面不查原始表 + +数据页不能直接查: + +- `question_monitor_runs` +- `question_monitor_parse_results` + +这些表只用于: + +- 问题详情 +- badcase 回放 +- 算法验证 + +## 10.2 页面只查汇总表 + +数据页查询路径应为: + +1. 先查 Redis +2. Redis miss 后查 PostgreSQL 汇总表 +3. 后台异步更新缓存 + +## 10.3 Redis Key 建议 + +可按以下维度缓存: + +- `brand:{brand_id}:keyword:{keyword_id}:model:{model_id}:range:30d:summary` +- `brand:{brand_id}:keyword:{keyword_id}:model:{model_id}:range:30d:trend` +- `brand:{brand_id}:keyword:{keyword_id}:model:{model_id}:range:30d:top_questions` +- `brand:{brand_id}:keyword:{keyword_id}:model:{model_id}:range:30d:citation_rank` + +## 11. V1 性能判断 + +针对当前页面,如果采用“按问题采集、按天汇总、按 30 天查询”的模型,PostgreSQL 是够用的。 + +原因是: + +- 页面查询只查 30 天汇总数据 +- 每次查询最多几十到几百行 +- 高频问题和排行榜都可提前汇总 +- Redis 可以承接高频重复查询 + +真正可能变大的不是页面查询,而是: + +- 原始问题执行任务量 +- 原始模型回答存储量 + +所以要重点控制的是任务调度和原始结果留存,而不是页面 SQL 本身。 + +## 12. V1 推荐保留策略 + +建议: + +- 原始运行记录保留 `30 ~ 90 天` +- 原始回答正文放对象存储 +- PostgreSQL 解析结果保留 `90 天` +- 日级汇总表保留 `180 天` 或更久 + +如果页面只看 30 天,也建议汇总表多保留一段时间,方便后续扩展。 + +## 13. 升级边界 + +当出现以下情况时,再升级架构: + +- 问题量暴涨 +- 模型数明显增加 +- 每日执行次数明显增加 +- 页面支持自由筛选、明细钻取 +- 监控维度从 30 天扩展到半年、一年 + +此时可以逐步升级为: + +- 独立分析库 +- 更大规模任务调度 +- 更复杂的去重复用逻辑 +- 专用分析引擎 + +## 14. V1 最终建议 + +结合你当前页面和品牌词库结构,V1 最合理的设计是: + +1. 以 `问题` 为最小监控采集单元。 +2. 以 `关键词` 作为问题的聚合容器。 +3. 以 `问题版本` 保证历史口径稳定。 +4. 以 `日级汇总表` 支撑页面查询。 +5. 以 `对象存储` 保存原始回答,避免 PostgreSQL 膨胀。 +6. 以 `问题 hash 去重` 控制执行量。 +7. 默认每题每天执行 1 次,避免 V1 数据量失控。 + +这样做,逻辑、数据量、页面性能三者才能对齐。 + diff --git a/docs/refer/品牌词库.png b/docs/refer/品牌词库.png new file mode 100644 index 0000000..07b3ae8 Binary files /dev/null and b/docs/refer/品牌词库.png differ diff --git a/docs/refer/媒体管理.png b/docs/refer/媒体管理.png new file mode 100644 index 0000000..06bb346 Binary files /dev/null and b/docs/refer/媒体管理.png differ diff --git a/docs/refer/工作台.png b/docs/refer/工作台.png new file mode 100644 index 0000000..96d8426 Binary files /dev/null and b/docs/refer/工作台.png differ diff --git a/docs/refer/数据管理.png b/docs/refer/数据管理.png new file mode 100644 index 0000000..eb2a644 Binary files /dev/null and b/docs/refer/数据管理.png differ diff --git a/docs/refer/文擎GEO/image 10.png b/docs/refer/文擎GEO/image 10.png new file mode 100644 index 0000000..9975a86 Binary files /dev/null and b/docs/refer/文擎GEO/image 10.png differ diff --git a/docs/refer/文擎GEO/image 11.png b/docs/refer/文擎GEO/image 11.png new file mode 100644 index 0000000..fe6c19e Binary files /dev/null and b/docs/refer/文擎GEO/image 11.png differ diff --git a/docs/refer/文擎GEO/image 12.png b/docs/refer/文擎GEO/image 12.png new file mode 100644 index 0000000..b01d281 Binary files /dev/null and b/docs/refer/文擎GEO/image 12.png differ diff --git a/docs/refer/文擎GEO/image 13.png b/docs/refer/文擎GEO/image 13.png new file mode 100644 index 0000000..0969421 Binary files /dev/null and b/docs/refer/文擎GEO/image 13.png differ diff --git a/docs/refer/文擎GEO/image 14.png b/docs/refer/文擎GEO/image 14.png new file mode 100644 index 0000000..11e7314 Binary files /dev/null and b/docs/refer/文擎GEO/image 14.png differ diff --git a/docs/refer/文擎GEO/image 15.png b/docs/refer/文擎GEO/image 15.png new file mode 100644 index 0000000..4b4170e Binary files /dev/null and b/docs/refer/文擎GEO/image 15.png differ diff --git a/docs/refer/文擎GEO/image 16.png b/docs/refer/文擎GEO/image 16.png new file mode 100644 index 0000000..c9f0c65 Binary files /dev/null and b/docs/refer/文擎GEO/image 16.png differ diff --git a/docs/refer/文擎GEO/image 17.png b/docs/refer/文擎GEO/image 17.png new file mode 100644 index 0000000..45b910f Binary files /dev/null and b/docs/refer/文擎GEO/image 17.png differ diff --git a/docs/refer/文擎GEO/image 18.png b/docs/refer/文擎GEO/image 18.png new file mode 100644 index 0000000..7b66747 Binary files /dev/null and b/docs/refer/文擎GEO/image 18.png differ diff --git a/docs/refer/文擎GEO/image 19.png b/docs/refer/文擎GEO/image 19.png new file mode 100644 index 0000000..3b98d64 Binary files /dev/null and b/docs/refer/文擎GEO/image 19.png differ diff --git a/docs/refer/文擎GEO/image 20.png b/docs/refer/文擎GEO/image 20.png new file mode 100644 index 0000000..95b66ff Binary files /dev/null and b/docs/refer/文擎GEO/image 20.png differ diff --git a/docs/refer/文擎GEO/image 5.png b/docs/refer/文擎GEO/image 5.png new file mode 100644 index 0000000..a1a7106 Binary files /dev/null and b/docs/refer/文擎GEO/image 5.png differ diff --git a/docs/refer/文擎GEO/image 6.png b/docs/refer/文擎GEO/image 6.png new file mode 100644 index 0000000..b306054 Binary files /dev/null and b/docs/refer/文擎GEO/image 6.png differ diff --git a/docs/refer/文擎GEO/image 7.png b/docs/refer/文擎GEO/image 7.png new file mode 100644 index 0000000..0fd9a32 Binary files /dev/null and b/docs/refer/文擎GEO/image 7.png differ diff --git a/docs/refer/文擎GEO/image 8.png b/docs/refer/文擎GEO/image 8.png new file mode 100644 index 0000000..d9aaba0 Binary files /dev/null and b/docs/refer/文擎GEO/image 8.png differ diff --git a/docs/refer/文擎GEO/image 9.png b/docs/refer/文擎GEO/image 9.png new file mode 100644 index 0000000..2197189 Binary files /dev/null and b/docs/refer/文擎GEO/image 9.png differ diff --git a/docs/refer/文擎GEO/image.png b/docs/refer/文擎GEO/image.png new file mode 100644 index 0000000..7c33550 Binary files /dev/null and b/docs/refer/文擎GEO/image.png differ diff --git a/docs/refer/文擎GEO/模版创作-新建模版-创建topx文章-步骤一.png b/docs/refer/文擎GEO/模版创作-新建模版-创建topx文章-步骤一.png new file mode 100644 index 0000000..4261659 Binary files /dev/null and b/docs/refer/文擎GEO/模版创作-新建模版-创建topx文章-步骤一.png differ diff --git a/docs/refer/文擎GEO/模版创作-新建模版-创建topx文章-步骤二.png b/docs/refer/文擎GEO/模版创作-新建模版-创建topx文章-步骤二.png new file mode 100644 index 0000000..8c7610b Binary files /dev/null and b/docs/refer/文擎GEO/模版创作-新建模版-创建topx文章-步骤二.png differ diff --git a/docs/refer/文擎GEO/模版创作-新建模版.png b/docs/refer/文擎GEO/模版创作-新建模版.png new file mode 100644 index 0000000..07c2b7d Binary files /dev/null and b/docs/refer/文擎GEO/模版创作-新建模版.png differ diff --git a/docs/refer/文擎GEO/模版创作.png b/docs/refer/文擎GEO/模版创作.png new file mode 100644 index 0000000..eb60cd0 Binary files /dev/null and b/docs/refer/文擎GEO/模版创作.png differ diff --git a/docs/refer/文章优化.png b/docs/refer/文章优化.png new file mode 100644 index 0000000..f209a04 Binary files /dev/null and b/docs/refer/文章优化.png differ diff --git a/docs/refer/模板创作.png b/docs/refer/模板创作.png new file mode 100644 index 0000000..f1d1a59 Binary files /dev/null and b/docs/refer/模板创作.png differ diff --git a/docs/refer/知识库.png b/docs/refer/知识库.png new file mode 100644 index 0000000..deafd04 Binary files /dev/null and b/docs/refer/知识库.png differ diff --git a/docs/refer/自定义生成.png b/docs/refer/自定义生成.png new file mode 100644 index 0000000..2de024c Binary files /dev/null and b/docs/refer/自定义生成.png differ diff --git a/docs/superpowers/specs/2026-03-31-admin-web-backend-core-design.md b/docs/superpowers/specs/2026-03-31-admin-web-backend-core-design.md new file mode 100644 index 0000000..3297adb --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-admin-web-backend-core-design.md @@ -0,0 +1,676 @@ +# GEO admin-web 后端核心链路设计 + +## 1. 概述 + +### 1.1 目标 + +搭建 GEO 平台 tenant-api 后端核心链路,为 admin-web 前端提供可用的 API 服务。第一阶段聚焦四个核心模块:认证、工作台、文章/模板、品牌词库,让前端能跑通内容生成主流程。 + +### 1.2 决策摘要 + +| 决策项 | 选择 | 理由 | +|--------|------|------| +| 进程模型 | 单进程 tenant-api | 当前只有 tenant 侧,gateway 无分发对象;鉴权中间件写在 shared 层,后续零成本拆出 | +| 脚手架 | 手工初始化 | sponge 生成的 handler/dao/service 命名与本项目 transport/app/domain/repository 四层结构不符,手工初始化成本更低 | +| Web 框架 | Gin | 架构文档指定 | +| DB 访问 | pgx + sqlc | 类型安全、性能最优、SQL 控制力强 | +| Migration | golang-migrate | 架构文档 §13.13.8 推荐 | +| 认证 | JWT (access + refresh) | 架构文档 §6 指定 | +| 日志 | zap 结构化 JSON | 架构文档 §13.13.10 推荐 | +| 配置 | YAML + env 覆盖 (viper) | 架构文档 §13.13.6 指定 | +| 错误码 | 统一格式 `{code, message, detail, request_id}` | 架构文档 §13.6.4 | + +### 1.3 不包含 + +第一阶段明确不做: +- gateway 独立进程 +- platform-api +- scheduler / worker-* +- prompt-rule、schedule、optimization、knowledge、media、tracking、account 模块 +- RabbitMQ / Qdrant / 对象存储集成 +- SSE 推送 + +这些在后续迭代中加入。 + +## 2. 项目结构 + +### 2.1 项目初始化 + +手工初始化 Go 项目(放弃 sponge),按架构文档 §5.2 的分层要求直接建目录结构: + +- `transport` — HTTP handler、路由注册 +- `app` — 业务逻辑 service +- `domain` — 领域模型(纯 Go 结构体,无框架依赖) +- `repository` — 数据访问(pgx + sqlc) + +### 2.2 目录结构 + +``` +server/ +├── cmd/ +│ └── tenant-api/ +│ └── main.go # 入口:初始化依赖、注册路由、启动 HTTP +├── internal/ +│ ├── bootstrap/ +│ │ └── bootstrap.go # 依赖容器:DB/Redis/Config 初始化 +│ ├── shared/ +│ │ ├── auth/ +│ │ │ ├── jwt.go # JWT 颁发 (access 15min + refresh 7day) +│ │ │ ├── middleware.go # Gin 中间件:Token 校验 + 身份注入 ctx +│ │ │ └── claims.go # JWT Claims 结构体 +│ │ ├── config/ +│ │ │ └── config.go # Viper 配置加载 +│ │ ├── middleware/ +│ │ │ ├── cors.go +│ │ │ ├── request_id.go # 生成/复用 request_id +│ │ │ ├── recovery.go +│ │ │ ├── logger.go # 请求日志 (zap) +│ │ │ └── tenant_scope.go # 从 JWT 提取 tenant_id 注入 ctx +│ │ ├── response/ +│ │ │ ├── response.go # 统一响应封装 Success/Error +│ │ │ └── errors.go # 错误码枚举与 AppError 类型 +│ │ ├── observability/ +│ │ │ └── logger.go # zap 初始化 +│ │ └── repository/ +│ │ ├── postgres/ +│ │ │ └── postgres.go # pgx 连接池 +│ │ └── redis/ +│ │ └── redis.go # Redis 连接 +│ ├── tenant/ +│ │ ├── transport/ +│ │ │ ├── router.go # 注册所有 tenant 路由组 +│ │ │ ├── auth_handler.go # /api/auth/* handlers +│ │ │ ├── workspace_handler.go +│ │ │ ├── template_handler.go +│ │ │ ├── article_handler.go +│ │ │ └── brand_handler.go +│ │ ├── app/ +│ │ │ ├── auth_service.go +│ │ │ ├── workspace_service.go +│ │ │ ├── template_service.go +│ │ │ ├── article_service.go +│ │ │ └── brand_service.go +│ │ ├── domain/ +│ │ │ ├── user.go +│ │ │ ├── tenant.go +│ │ │ ├── article.go +│ │ │ ├── article_template.go +│ │ │ ├── article_version.go +│ │ │ ├── generation_task.go +│ │ │ ├── brand.go +│ │ │ ├── brand_keyword.go +│ │ │ ├── brand_question.go +│ │ │ └── competitor.go +│ │ └── repository/ +│ │ ├── queries/ # sqlc SQL 文件 +│ │ │ ├── user.sql +│ │ │ ├── tenant.sql +│ │ │ ├── article.sql +│ │ │ ├── article_template.sql +│ │ │ ├── brand.sql +│ │ │ └── ... +│ │ ├── sqlc.yaml # sqlc 配置 +│ │ ├── generated/ # sqlc 生成的 Go 代码 +│ │ ├── user_repo.go # Repository 接口 + 实现 +│ │ ├── article_repo.go +│ │ ├── template_repo.go +│ │ └── brand_repo.go +│ └── gateway/ # 预留空目录 +├── migrations/ # golang-migrate SQL 文件 +│ ├── 20260331100000_create_tenants.up.sql +│ ├── 20260331100000_create_tenants.down.sql +│ ├── 20260331100001_create_users.up.sql +│ ├── ... +├── configs/ +│ ├── config.yaml # 默认配置 +│ └── config.local.yaml # 本地开发覆盖 (gitignore) +├── go.mod +├── go.sum +├── Makefile # dev-init, dev-api, migrate-up, sqlc-generate +└── docker-compose.yaml # PostgreSQL + Redis (本地开发) +``` + +## 3. 数据库 Schema + +### 3.1 Migration 文件规划 + +按架构文档 §13.11,第一阶段需要以下表。命名规则:`YYYYMMDDHHMMSS_description.up.sql`。 + +#### 3.1.1 身份与租户 + +```sql +-- 20260331100000_create_tenants.up.sql +CREATE TABLE tenants ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, frozen + frozen_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uk_tenant_name UNIQUE (name) +); + +-- 20260331100001_create_users.up.sql +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL, + phone VARCHAR(20), + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL, + avatar_url TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uk_users_email UNIQUE (email) +); + +-- 20260331100002_create_tenant_memberships.up.sql +CREATE TABLE tenant_memberships ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + user_id BIGINT NOT NULL REFERENCES users(id), + tenant_role VARCHAR(20) NOT NULL, -- tenant_admin, editor, kol + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uk_tenant_user UNIQUE (tenant_id, user_id) +); + +-- 20260331100003_create_platform_user_roles.up.sql +CREATE TABLE platform_user_roles ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + platform_role VARCHAR(20) NOT NULL, -- super_admin, ops, sre + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uk_platform_user_role UNIQUE (user_id, platform_role) +); + +-- 20260331100004_create_plans.up.sql +CREATE TABLE plans ( + id BIGSERIAL PRIMARY KEY, + plan_code VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + quota_policy_json JSONB NOT NULL DEFAULT '{}', + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uk_plan_code UNIQUE (plan_code) +); + +-- 20260331100005_create_tenant_plan_subscriptions.up.sql +CREATE TABLE tenant_plan_subscriptions ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + plan_id BIGINT NOT NULL REFERENCES plans(id), + start_at TIMESTAMPTZ NOT NULL, + end_at TIMESTAMPTZ NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX idx_tenant_plan_active ON tenant_plan_subscriptions(tenant_id) WHERE status = 'active'; -- 同一时间只有一个活跃订阅 + +-- 20260331100006_create_tenant_quota_ledgers.up.sql +CREATE TABLE tenant_quota_ledgers ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + quota_type VARCHAR(50) NOT NULL, -- article_generation + delta INT NOT NULL, + balance_after INT NOT NULL, + reason VARCHAR(255), + reference_type VARCHAR(50), + reference_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_tenant_quota_tenant_type_created ON tenant_quota_ledgers(tenant_id, quota_type, created_at DESC); + +-- 20260331100007_create_quota_reservations.up.sql +CREATE TABLE quota_reservations ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + quota_type VARCHAR(50) NOT NULL, + resource_type VARCHAR(50) NOT NULL, + resource_id BIGINT NOT NULL, + reserved_amount INT NOT NULL, + consumed_amount INT NOT NULL DEFAULT 0, + refunded_amount INT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, confirmed, refunded, expired + expire_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_quota_reservation_tenant_status ON quota_reservations(tenant_id, status); +``` + +#### 3.1.2 模板与文章 + +```sql +-- 20260331100010_create_article_templates.up.sql +CREATE TABLE article_templates ( + id BIGSERIAL PRIMARY KEY, + scope VARCHAR(20) NOT NULL, -- platform, tenant + tenant_id BIGINT REFERENCES tenants(id), + origin_type VARCHAR(20) NOT NULL DEFAULT 'platform', -- platform, tenant_custom, kol_share + share_code_id BIGINT, + template_key VARCHAR(100) NOT NULL, + template_name VARCHAR(200) NOT NULL, + schema_json JSONB NOT NULL DEFAULT '{}', + prompt_template TEXT, + prompt_visibility VARCHAR(20) NOT NULL DEFAULT 'visible', -- visible, sealed + protected_prompt_asset_key TEXT, + card_config_json JSONB NOT NULL DEFAULT '{}', + status VARCHAR(20) NOT NULL DEFAULT 'active', + version_no INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uk_template_scope_key_version UNIQUE (scope, tenant_id, template_key, version_no) +); + +-- 20260331100011_create_articles.up.sql +CREATE TABLE articles ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + source_type VARCHAR(30) NOT NULL, -- template, prompt_rule, optimization + template_id BIGINT REFERENCES article_templates(id), + prompt_rule_id BIGINT, + current_version_id BIGINT, + generate_status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, generating, completed, failed + publish_status VARCHAR(20) NOT NULL DEFAULT 'unpublished', -- unpublished, publishing, success, failed, pending_review + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX idx_article_tenant_created ON articles(tenant_id, created_at DESC); +CREATE INDEX idx_article_tenant_status ON articles(tenant_id, generate_status, publish_status, created_at DESC); + +-- 20260331100012_create_article_versions.up.sql +CREATE TABLE article_versions ( + id BIGSERIAL PRIMARY KEY, + article_id BIGINT NOT NULL REFERENCES articles(id), + version_no INT NOT NULL, + title VARCHAR(500), + html_content TEXT, + markdown_content TEXT, + word_count INT NOT NULL DEFAULT 0, + source_label VARCHAR(100), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_article_version_no UNIQUE (article_id, version_no) +); + +-- 20260331100013_create_generation_tasks.up.sql +CREATE TABLE generation_tasks ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + article_id BIGINT REFERENCES articles(id), + task_batch_id VARCHAR(100), + quota_reservation_id BIGINT REFERENCES quota_reservations(id), + task_type VARCHAR(50) NOT NULL, -- template, prompt, batch_item, schedule_item, optimize + request_hash VARCHAR(64), + input_params_json JSONB, + status VARCHAR(20) NOT NULL DEFAULT 'queued', -- queued, running, completed, failed + error_message TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_generation_task_status_created ON generation_tasks(status, created_at); +CREATE INDEX idx_generation_task_tenant ON generation_tasks(tenant_id, created_at DESC); +``` + +#### 3.1.3 品牌 + +```sql +-- 20260331100020_create_brands.up.sql +CREATE TABLE brands ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + name VARCHAR(200) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uk_brand_tenant_name UNIQUE (tenant_id, name) +); + +-- 20260331100021_create_brand_keywords.up.sql +CREATE TABLE brand_keywords ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + brand_id BIGINT NOT NULL REFERENCES brands(id), + name VARCHAR(200) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uk_brand_keyword_name UNIQUE (brand_id, name) +); + +-- 20260331100022_create_brand_questions.up.sql +CREATE TABLE brand_questions ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + brand_id BIGINT NOT NULL REFERENCES brands(id), + keyword_id BIGINT NOT NULL REFERENCES brand_keywords(id), + current_version_id BIGINT, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX idx_brand_questions_keyword ON brand_questions(keyword_id); + +-- 20260331100023_create_brand_question_versions.up.sql +CREATE TABLE brand_question_versions ( + id BIGSERIAL PRIMARY KEY, + question_id BIGINT NOT NULL REFERENCES brand_questions(id), + question_text TEXT NOT NULL, + question_hash VARCHAR(64) NOT NULL, + version_no INT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_question_version_no UNIQUE (question_id, version_no) +); +CREATE INDEX idx_question_hash ON brand_question_versions(question_hash); + +-- 20260331100024_create_competitors.up.sql +CREATE TABLE competitors ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + brand_id BIGINT NOT NULL REFERENCES brands(id), + name VARCHAR(200) NOT NULL, + website TEXT, + description TEXT, + product_lines_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX idx_competitor_brand ON competitors(brand_id); +``` + +#### 3.1.4 基础设施 + +```sql +-- 20260331100030_create_task_records.up.sql +CREATE TABLE task_records ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + task_type VARCHAR(50) NOT NULL, + resource_type VARCHAR(50) NOT NULL, + resource_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + retry_count INT NOT NULL DEFAULT 0, + task_batch_id VARCHAR(100), + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_task_record_status_type ON task_records(status, task_type); + +-- 20260331100031_create_audit_logs.up.sql +CREATE TABLE audit_logs ( + id BIGSERIAL PRIMARY KEY, + operator_id BIGINT NOT NULL REFERENCES users(id), + tenant_id BIGINT, + module VARCHAR(50) NOT NULL, + action VARCHAR(50) NOT NULL, + before_json JSONB, + after_json JSONB, + result VARCHAR(20), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_audit_module_created ON audit_logs(module, created_at DESC); +``` + +### 3.2 种子数据 + +`make dev-init` 需要写入最小种子数据: + +- 1 个默认租户 +- 1 个 tenant_admin 用户 (admin@geo.local / 默认密码) +- 1 个 tenant_membership +- 1 个免费套餐 (plan) +- 1 个订阅关系 +- 初始额度记录 +- 3-4 个平台预设模板 (Top X 文章、产品评测文章、研究报告文章、品牌词搜索扩写) + +## 4. API 设计 + +### 4.1 统一响应格式 + +所有 API 返回: + +```json +// 成功 +{ + "code": 0, + "message": "ok", + "data": { ... }, + "request_id": "req_xxx" +} + +// 失败 +{ + "code": 40301, + "message": "quota_insufficient", + "detail": "生成额度不足,请升级套餐或等待额度重置", + "request_id": "req_xxx" +} +``` + +错误码段 (§13.6.4):`400xx` 参数错误, `401xx` 认证失败, `403xx` 权限/额度不足, `404xx` 资源不存在, `409xx` 状态冲突, `429xx` 频率限制, `500xx` 内部错误, `503xx` 下游不可用。 + +### 4.2 分页格式 + +请求:`?page=1&page_size=20&sort=created_at&order=desc` + +响应 data 字段: + +```json +{ + "items": [...], + "total": 100, + "page": 1, + "page_size": 20 +} +``` + +### 4.3 Auth API + +| Method | Path | 说明 | +|--------|------|------| +| POST | `/api/auth/login` | 邮箱+密码登录,返回 access_token + refresh_token | +| POST | `/api/auth/refresh` | 刷新 access_token | +| GET | `/api/auth/me` | 返回当前用户信息、tenant_id、tenant_role、permissions[] | +| POST | `/api/auth/logout` | 登出 (可选:加入 token 黑名单) | + +**JWT Claims**: +```go +type Claims struct { + UserID int64 `json:"user_id"` + TenantID int64 `json:"tenant_id"` + Role string `json:"role"` // tenant_admin, editor + jwt.RegisteredClaims +} +``` + +- Access token TTL: 15 分钟 +- Refresh token TTL: 7 天 +- Refresh token 存 Redis,支持主动失效 + +### 4.4 Workspace API + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/tenant/workspace/overview` | 聚合统计:文章数、发布数、品牌数、媒体平台数 | +| GET | `/api/tenant/workspace/recent-articles` | 最近 10 篇文章 (带生成状态/发布状态) | +| GET | `/api/tenant/workspace/quota-summary` | 当前套餐额度:已用/总量/重置时间 | +| GET | `/api/tenant/workspace/template-cards` | 可用模板卡片列表 (平台预设 + 租户自建) | + +workspace_service 聚合多个 repository 数据,单次返回,避免前端首屏发起过多散请求 (§13.2)。 + +### 4.5 Template API + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/tenant/templates` | 模板列表 (scope=platform 或 tenant_id 匹配) | +| GET | `/api/tenant/templates/{id}` | 模板详情 (prompt_visibility=sealed 时不返回 prompt_template) | +| GET | `/api/tenant/templates/{id}/schema` | 模板表单 schema (JSON) | +| POST | `/api/tenant/templates/{id}/generate` | 提交生成任务 | + +**生成流程** (§10.1): +1. 校验租户身份、额度、参数 +2. 在单个 PG 事务内:额度预扣 + generation_tasks 创建 + articles 占位记录 +3. 返回 task_id 和 article_id +4. (V1 第一阶段:任务状态模拟为同步完成或标记为 queued 等待 worker) + +### 4.6 Article API + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/tenant/articles` | 文章列表 (分页、筛选:generate_status, publish_status, source_type, template_id, keyword, date_range) | +| GET | `/api/tenant/articles/{id}` | 文章详情 (含当前版本内容) | +| GET | `/api/tenant/articles/{id}/versions` | 文章版本列表 | +| DELETE | `/api/tenant/articles/{id}` | 删除文章 | + +### 4.7 Brand API + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/tenant/brands` | 品牌列表 | +| POST | `/api/tenant/brands` | 创建品牌 | +| GET | `/api/tenant/brands/{id}` | 品牌详情 | +| PUT | `/api/tenant/brands/{id}` | 更新品牌 | +| DELETE | `/api/tenant/brands/{id}` | 删除品牌 | +| GET | `/api/tenant/brands/{id}/keywords` | 关键词列表 | +| POST | `/api/tenant/brands/{id}/keywords` | 创建关键词 | +| PUT | `/api/tenant/brands/{id}/keywords/{kid}` | 更新关键词 | +| DELETE | `/api/tenant/brands/{id}/keywords/{kid}` | 删除关键词 | +| GET | `/api/tenant/brands/{id}/questions` | 问题列表 (按关键词筛选) | +| POST | `/api/tenant/brands/{id}/questions` | 创建问题 (自动创建 v1 版本) | +| GET | `/api/tenant/brands/{id}/question-versions` | 问题版本列表 | +| GET | `/api/tenant/brands/{id}/competitors` | 竞品列表 | +| POST | `/api/tenant/brands/{id}/competitors` | 创建竞品 | +| PUT | `/api/tenant/brands/{id}/competitors/{cid}` | 更新竞品 | +| DELETE | `/api/tenant/brands/{id}/competitors/{cid}` | 删除竞品 | + +### 4.8 Health Check + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/health/live` | 存活检查 | +| GET | `/api/health/ready` | 就绪检查 (DB + Redis 可达) | + +## 5. 中间件链 + +请求处理顺序 (§6.3): + +1. Recovery (panic 恢复) +2. RequestID (生成/复用 request_id) +3. Logger (请求日志) +4. CORS +5. Auth (JWT 校验 + 身份注入 ctx) — `/api/auth/login` 和 `/api/health/*` 除外 +6. TenantScope (从 JWT 提取 tenant_id 注入 ctx) +7. Handler + +### 5.1 TenantScope 实现 + +```go +// 从 context 中获取 tenant_id,repository 层必须调用 +func TenantIDFromCtx(ctx context.Context) int64 +``` + +所有 tenant repository 方法的 SQL 必须包含 `WHERE tenant_id = $tenant_id`。sqlc 生成的查询默认带上此参数。 + +## 6. 本地开发 + +### 6.1 docker-compose.yaml + +仅启动基础设施: + +```yaml +services: + postgres: + image: postgres:16 + ports: ["5432:5432"] + environment: + POSTGRES_DB: geo + POSTGRES_USER: geo + POSTGRES_PASSWORD: geo_dev + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: ["6379:6379"] + +volumes: + pgdata: +``` + +### 6.2 Makefile 命令 + +```makefile +dev-init: # docker compose up -d + migrate up + seed data +dev-api: # go run ./server/cmd/tenant-api +migrate-up: # golang-migrate up +migrate-down: # golang-migrate down 1 +migrate-create: # 创建新 migration 文件 +sqlc-generate: # sqlc generate +lint: # golangci-lint run +test: # go test ./... +``` + +### 6.3 配置文件 + +```yaml +# configs/config.yaml +server: + port: 8080 + mode: debug # debug / release + +database: + host: localhost + port: 5432 + user: geo + password: geo_dev + dbname: geo + sslmode: disable + max_open_conns: 25 + max_idle_conns: 5 + +redis: + addr: localhost:6379 + db: 0 + +jwt: + secret: "${JWT_SECRET:dev-secret-change-in-production}" + access_ttl: 15m + refresh_ttl: 168h # 7 days + +log: + level: debug + format: json +``` + +## 7. 验证方式 + +1. `make dev-init` 启动基础设施并初始化数据库 +2. `make dev-api` 启动 tenant-api +3. `POST /api/auth/login` 登录获取 token +4. `GET /api/auth/me` 验证身份 +5. `GET /api/tenant/workspace/overview` 验证工作台数据 +6. `GET /api/tenant/templates` 查看预设模板 +7. `POST /api/tenant/brands` 创建品牌 +8. `GET /api/tenant/brands/{id}/keywords` 查看关键词 +9. `make test` 所有测试通过 +10. `make lint` 无 lint 错误 diff --git a/findings.md b/findings.md new file mode 100644 index 0000000..4eb3eab --- /dev/null +++ b/findings.md @@ -0,0 +1,100 @@ +# Findings & Decisions + +## Requirements +- Verify the repository against the 9-step implementation plan provided by the user. +- Use `/Users/liangxu/Documents/test/geo-rankly/docs/superpowers/specs/2026-03-31-admin-web-backend-core-design.md` as the source of truth for the next development slice. +- Continue development immediately after identifying the earliest unfinished step. + +## Research Findings +- The repository already contains core backend scaffolding under `server/`, including `cmd/tenant-api`, `internal/bootstrap`, `shared`, `tenant`, `migrations`, and SQL query files. +- `server/Makefile` already defines `dev-init`, `dev-api`, `migrate-up`, `sqlc-generate`, `seed`, `lint`, and `test`. +- The bootstrap layer already exposes `/api/health/live` and `/api/health/ready`. +- The design doc defines a sequential 9-step delivery plan and expects `sqlc` generated code under `internal/tenant/repository/generated/`, plus repository wrappers around it. +- The repository has 19 migration `.up.sql` files, matching the plan's schema count, and `cmd/dev-seed` seeds `admin@geo.local` plus plan/quota/template data. +- `internal/tenant/repository/` currently contains only query SQL, `sqlc.yaml`, `tx.go`, and placeholder integration tests; there is no `generated/` directory and no repository wrapper implementations. +- `auth_service.go`, `workspace_service.go`, `template_service.go`, `article_service.go`, and `brand_service.go` still issue SQL directly against `pgxpool.Pool`, which contradicts the design doc's Step 4 target. +- Running `make sqlc-generate` currently fails because `server/internal/tenant/repository/sqlc.yaml` sets `schema: "../../migrations/"`, which resolves to a non-existent path. The correct repository-relative path should point to `server/migrations`. +- After fixing `sqlc.yaml`, `make sqlc-generate` succeeds and produces typed code in `server/internal/tenant/repository/generated/`. +- Repository wrappers now exist for auth, workspace, template, article, quota, and audit queries, and the auth/workspace/template services consume them on the main code path. +- Refresh token rotation is now atomic in Redis via a Lua script in `session_store.go`, with tests covering successful rotation and hash mismatch rejection. +- `make test` and `make lint` both pass locally after cleaning up existing lint violations. +- A GitHub Actions workflow now exists at `.github/workflows/backend-ci.yml`; it runs `sqlc` generation, verifies generated code is committed, executes the tenant SQL scope guard, runs tests, and runs `golangci-lint`. +- The `server/Makefile` now exposes `tenant-scope-guard`, so the tenant filter check is available both locally and in CI. +- The `server/Makefile` no longer depends on a preinstalled `migrate` binary; it now runs `golang-migrate` via `go run -tags postgres`, which allowed `make dev-init` to run successfully on this machine. +- `make dev-init` now completes end-to-end: Docker services start, all 19 migrations apply, and seed data creates tenant/user/plan/template records. +- Runtime verification succeeded for `/api/health/live`, `/api/health/ready`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/refresh`, and `POST /api/auth/logout`. +- Negative auth checks also passed at runtime: the old access token returns `40103 token_revoked` after logout, and the old refresh token returns `40121 refresh_session_expired` after rotation. +- Workspace runtime verification passed for all four first-screen endpoints: `overview`, `recent-articles`, `quota-summary`, and `template-cards`. +- Template generation runtime verification passed end-to-end: creating a generation task produced an article, article detail returned `generate_status=completed`, article versions returned one version, and quota balance decreased from 100 to 99. +- Brand runtime verification passed for create keyword/question/competitor flows, question version history returned two versions after update, and soft-deleted brands can be recreated with the same name. +- The repository still has no `apps/` or `packages/` directories, so neither `admin-web` nor `ops-admin-web` has been started. +- The frontend architecture doc recommends `pnpm workspace + Vue 3 + TypeScript + ant-design-vue + Vite`, with shared packages for UI, shared DTOs, HTTP client, and TS config. +- The current backend already exposes enough stable endpoints for a first `admin-web` slice: `POST /api/auth/login`, `GET /api/auth/me`, and the four workspace endpoints under `/api/tenant/workspace/*`. +- Backend responses use a consistent envelope: `code`, `message`, `data`, and `request_id`, which makes a shared frontend API client straightforward. +- Local toolchain support for the frontend is present on this machine: `node v22.20.0`, `npm 10.9.3`, and `pnpm 10.28.2`. +- A new pnpm workspace now exists at the repo root, with `apps/admin-web` plus shared packages under `packages/shared-types`, `packages/http-client`, and `packages/tsconfig`. +- `apps/admin-web` is now a working Vue 3 + TypeScript + Vite 8 app with Ant Design Vue, Pinia, Vue Router, and Vue Query wired in. +- The frontend implements a persistent auth session store, login flow, route guard, auto-refresh-capable API client, and a tenant-facing application shell. +- The workspace page is wired to live backend data through the four dashboard endpoints and renders template cards, stats, quota, and recent articles. +- Placeholder routes now exist for template creation, custom generation, optimization, media, brands, tracking, and knowledge so the navigation shell is ready for incremental page delivery. +- `pnpm build:admin` passes successfully, producing a production build under `apps/admin-web/dist/`. +- Static preview verification passed via `vite preview` and HTTP checks against `http://127.0.0.1:4173/login`, which returned `HTTP/1.1 200 OK` plus the built asset references. +- Gemini CLI was tested but then abandoned for this turn: `gemini-3.1-pro-preview` consistently returned `429 MODEL_CAPACITY_EXHAUSTED`, and the user explicitly requested not to use Gemini afterward. +- After the user indicated Playwright was fixed, browser-level verification succeeded through the Playwright CLI path rather than the broken MCP path. +- Browser testing exposed one real frontend bug: the login CTA rendered correctly but did not submit the form on click until it was explicitly bound to `handleSubmit`. +- After the login-button fix, a Playwright end-to-end flow successfully loaded `/login`, signed in with the seeded account, reached `/workspace`, and verified the presence of the workspace heading plus the template and recent-content sections. +- The production bundle size improved substantially after switching Ant Design Vue registration from full-plugin mode to per-component registration; the main JS chunk dropped from roughly `1.4 MB` to about `747 kB` before gzip. +- The currently exposed tenant-facing backend interfaces fall into four frontend-covered groups: auth, workspace, templates/articles, and brands. +- The reference screenshots were re-read using position-aware OCR extraction, which made the layout intent clear beyond the raw text labels. +- `docs/refer/工作台.png` places the main content in a left-heavy column with template cards above recent articles, while the right side holds the compact stats cluster; the quota/plan card sits in the left sidebar footer rather than the main canvas. +- `docs/refer/模板创作.png` uses a top page hero, a horizontal filter/action strip, an article table below it, and a separate template-selection layer before entering a 3-step generation flow. +- `docs/refer/文擎GEO/模版创作-新建模版-创建topx文章-步骤一.png` and `...步骤二.png` show the generation flow as a wizard: step 1 basic info plus brand/keyword/competitor context, step 2 title and structure selection, step 3 generation. +- `docs/refer/品牌词库.png` uses a top brand card rail, then a split content area with keyword navigation on the left and question management on the right; the competitor library lives under a sibling tab. +- `docs/refer/媒体管理.png` confirms the overall visual system: page hero under the content header, three-step guide card near the top, and white cards on a cool gray background with the same left navigation shell. +- The user explicitly asked for i18n and styling discipline during implementation: text should move behind translation keys, and component styles should live in `