diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 090cde1a..8125ecc0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3197,3 +3197,18 @@ - 防复发:每个 requested theme 必须且只能由 Claim Card 或 Blocked Section 覆盖;未执行或 blocked/partial 技法不得升级为 verified/consensus;Agent 不得接收 raw workflow、坐标、内部路径、secret、聊天历史或工具轨迹;Bundle hash 必须由服务器对规范化且排除自身 hash 的内容计算。报告文档、job 状态和日志不得写入用户资料、密钥、内部 URL 或模型自由生成的 HTML/CSS/SVG;不得把本地测试结果虚构为 staging 或生产验收。 - 相关记录:BUG-152、BUG-159 - 修复版本:本次功能分支提交(精确 SHA 以提交与远程分支核对结果为准;未合并 staging,未部署) + +## BUG-189 | 普通咨询域与独立产品域漂移,导致多域请求、专题证据与 UI 状态越界 + +- 状态:resolved(本地候选,未部署) +- 首次发现:2026-08-14 +- 最近更新:2026-08-14 +- 影响面:普通咨询入口与会话持久化、Mastra 多域计划、Python 咨询编排与专题报告、`chat_sessions.theme` 约束、生时校正 Activity/候选文案,以及个人报告、合盘、生时校正独立产品 API。 +- 用户现象:同一个自然语言问题涉及多个主题时只能落入单一 `theme`;前端、API、Python 与数据库允许域不一致,新增普通咨询域可能在写会话或专题执行时失败;不具备原生专题证据的域可能被静默当成 `general`;候选采用曾显示成“已确认”,流式正文到达后会清除真实运行中 Activity,失败回合的服务端 receipt 也可能丢失;独立产品入口与 API 缺少统一的服务端产品开关。 +- 触发条件:请求 education/migration/family/annual 等旧数据库或旧专题枚举未覆盖的普通咨询;Agent 从同一问题规划多个咨询域;采用但未确认生时候选;工具运行中先收到 `answer.delta` 或工具失败;关闭独立产品但直接调用其写入/计算 API。 +- 根因:咨询域在多个客户端与服务端枚举中重复维护,单 `theme` 合同无法表达多域;专题报告只认识旧主题枚举;UI 把 accepted 与 confirmed、正文与真实执行状态混合;独立产品没有与普通咨询 taxonomy 分离的 registry 和 fail-closed 服务端 gate。 +- 修复:建立十个 canonical consultation domain 的 TypeScript/Python 单一 registry、alias 规范化与最多六域的有序去重计划,Agent 按域逐一执行并由服务器聚合真实 domains receipt;数据库前向迁移同步 `chat_sessions.theme` 约束。career/marriage/wealth/health 使用原生专题报告,其余 canonical 域返回明确 degraded/blocked adapter,未知域和独立产品域 fail closed,`annual` 普通咨询不等于 `annual_report`。UI 保留 answer delta 期间的真实 Activity 与失败 receipt,只从服务端 event/receipt 渲染执行状态,并严格区分“已采用(未确认)”和“已确认”。另建独立 product registry 与 feature flags;个人报告、合盘、生时校正写入/计算 API 在鉴权后执行 fail-closed 产品 gate,报告中心同时保留既有环境变量开关。 +- 验证:TypeScript focused 矩阵 178 passed、0 failed;Python consultation/thematic/API focused 70 passed、0 failed,`py_compile` 通过;全部 PR-7 TypeScript/TSX 目标 ESLint 与 `git diff --check` 通过。全库 `tsc --noEmit` 仍只有 5 个既有无关测试错误:`production-data-migration.test.ts` 两处 fixture 字段缺失、`staging-backend-workflows.test.ts` 三处低 target 正则 flag。全量 Ruff 会报告 legacy Python 文件既有存量问题,因此未将其声明为 PR-7 通过门禁;未应用任何远端数据库迁移,未部署。 +- 防复发:普通咨询只能持久化 canonical consultation domain,独立产品 ID 永不进入 `chat_sessions.theme`;多域执行必须保序、去重、逐域产生真实 receipt,未执行能力不得伪装为 `general` 或 verified;accepted 不得升级为 confirmed,Activity 不得从 Agent 正文推导;所有独立产品写入/计算 API 必须执行服务端 fail-closed gate,数据库迁移保持前向兼容且默认关闭未发布产品。 +- 相关记录:BUG-159、BUG-181、BUG-185、BUG-188 +- 修复版本:本次功能分支提交(精确 SHA 以提交与远程分支核对结果为准;未合并 main/staging,未部署) diff --git a/frontend/db/migrations/20260814050000_consultation_domain_registry.sql b/frontend/db/migrations/20260814050000_consultation_domain_registry.sql new file mode 120000 index 00000000..8fa21492 --- /dev/null +++ b/frontend/db/migrations/20260814050000_consultation_domain_registry.sql @@ -0,0 +1 @@ +../../supabase/migrations/20260814050000_consultation_domain_registry.sql \ No newline at end of file diff --git a/frontend/db/migrations/20260814060000_product_feature_flags.sql b/frontend/db/migrations/20260814060000_product_feature_flags.sql new file mode 120000 index 00000000..295aeaab --- /dev/null +++ b/frontend/db/migrations/20260814060000_product_feature_flags.sql @@ -0,0 +1 @@ +../../supabase/migrations/20260814060000_product_feature_flags.sql \ No newline at end of file diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index b5c1519c..96992d1d 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -8,6 +8,7 @@ import { runConsultationWorkflow, } from "@/mastra"; import { blocksPromptExtraction } from "@/lib/consult-safety"; +import { consultationDomainSchema } from "@/lib/consultation-domain-registry"; import { parseAgentReply } from "@/lib/agent-reply"; import { consultationEntrypointSchema, @@ -70,7 +71,7 @@ const generalChatRequestSchema = z.object({ ...chatRequestMetadataSchema.shape, consultationMode: z.literal("general_no_birth_time"), question: z.string().trim().min(1).max(500), - theme: z.enum(["career", "marriage", "wealth", "timing", "general"]), + theme: consultationDomainSchema, entrypoint: z.undefined().optional(), }).strict(); diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index a790fcf0..49944793 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -8,6 +8,7 @@ import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "@/lib/rec import { blocksPromptExtraction } from "@/lib/consult-safety"; import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing"; import { loadRuntimeFeatureFlags } from "@/lib/feature-flags"; +import { isProductEnabled } from "@/lib/product-access"; import { resolveSessionLanguageModel } from "@/lib/model-catalog"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -81,6 +82,13 @@ export async function POST(request: Request) { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } + if (!await isProductEnabled("rectification")) { + return NextResponse.json( + { error: "生时校正服务暂未开放", code: "rectification_product_disabled" }, + { status: 503 }, + ); + } + const parsed = agentRequestSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( diff --git a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts index 199ac2c5..6e99ae88 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { isProductEnabled } from "@/lib/product-access"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { RectificationToolServiceError } from "@/lib/rectification-agentic/v9/tool-service"; @@ -63,6 +64,13 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } + if (!await isProductEnabled("rectification")) { + return NextResponse.json( + { error: "生时校正服务暂未开放", code: "rectification_product_disabled" }, + { status: 503 }, + ); + } + const { caseId } = await context.params; if (!z.string().uuid().safeParse(caseId).success) { return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 }); diff --git a/frontend/src/app/api/rectification/cases/open/route.ts b/frontend/src/app/api/rectification/cases/open/route.ts index 43804989..76a30528 100644 --- a/frontend/src/app/api/rectification/cases/open/route.ts +++ b/frontend/src/app/api/rectification/cases/open/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { isProductEnabled } from "@/lib/product-access"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { RectificationCaseServiceError, @@ -47,6 +48,13 @@ export async function POST(request: Request) { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } + if (!await isProductEnabled("rectification")) { + return NextResponse.json( + { error: "生时校正服务暂未开放", code: "rectification_product_disabled" }, + { status: 503 }, + ); + } + const parsed = parseOpenRectificationCaseRequest( await request.json().catch(() => null), ); diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index a46f0650..f0e91e26 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -6,6 +6,7 @@ import { resolveSkillSnapshot, } from "@/lib/personal-report-generation"; import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes"; +import { isProductEnabled } from "@/lib/product-access"; import { isPersonalReportFeatureEnabled, readPersonalReportDailyLimit, @@ -107,6 +108,9 @@ export async function POST(request: Request) { const supabase = await createServerSupabaseClient(); const { data: { user }, error: authError } = await supabase.auth.getUser(); const userId = authError || !user ? null : user.id; + const reportProductEnabled = userId + ? await isProductEnabled("report_center") + : false; // Admin client (service_role / self-hosted admin DB): generation writes // and counting. The authenticated client is forbidden by migration grants @@ -157,7 +161,7 @@ export async function POST(request: Request) { if (error) throw error; return Boolean(data); }, - featureEnabled: isPersonalReportFeatureEnabled(process.env), + featureEnabled: reportProductEnabled && isPersonalReportFeatureEnabled(process.env), dailyLimit: readPersonalReportDailyLimit(process.env), counts: { countGenerating: async () => { diff --git a/frontend/src/app/api/synastry-reports/route.ts b/frontend/src/app/api/synastry-reports/route.ts index e51a2b81..3ebcc869 100644 --- a/frontend/src/app/api/synastry-reports/route.ts +++ b/frontend/src/app/api/synastry-reports/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { isProductEnabled } from "@/lib/product-access"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -17,6 +18,12 @@ export async function GET() { const supabase = await createServerSupabaseClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + if (!await isProductEnabled("compatibility")) { + return NextResponse.json( + { error: "合盘服务暂未开放", code: "compatibility_product_disabled" }, + { status: 503 }, + ); + } const { data, error } = await supabase .from("synastry_reports") @@ -37,15 +44,21 @@ export async function GET() { export async function POST(request: Request) { try { + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + if (!await isProductEnabled("compatibility")) { + return NextResponse.json( + { error: "合盘服务暂未开放", code: "compatibility_product_disabled" }, + { status: 503 }, + ); + } + const body = await request.json().catch(() => null) as SynastryReportPayload | null; if (!body?.report || typeof body.report !== "object" || Array.isArray(body.report)) { return NextResponse.json({ error: "合盘报告格式不正确" }, { status: 400 }); } - const supabase = await createServerSupabaseClient(); - const { data: { user } } = await supabase.auth.getUser(); - if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); - const partnerName = body.partnerName?.trim() || "对方"; const record = { ...(body.id ? { id: body.id } : {}), diff --git a/frontend/src/app/api/synastry/route.ts b/frontend/src/app/api/synastry/route.ts index 96429049..85a5023a 100644 --- a/frontend/src/app/api/synastry/route.ts +++ b/frontend/src/app/api/synastry/route.ts @@ -1,4 +1,6 @@ import { NextResponse } from "next/server"; +import { isProductEnabled } from "@/lib/product-access"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; import { synastryBirthPayload, type GlobalBirthProfile as Profile, @@ -116,6 +118,18 @@ async function postPython(path: string, body: unknown) { export async function POST(request: Request) { try { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + if (!await isProductEnabled("compatibility")) { + return NextResponse.json( + { error: "合盘服务暂未开放", code: "compatibility_product_disabled" }, + { status: 503 }, + ); + } + const body = await request.json().catch(() => null) as { selfProfile?: Profile; partnerProfile?: Profile; relationshipType?: RelationshipType } | null; if (!body?.selfProfile || !body.partnerProfile) { return NextResponse.json({ error: "请提供双方星盘资料" }, { status: 400 }); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index a0dbb1a1..8d230eb8 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -77,6 +77,7 @@ import { previewRectificationJourney, } from "@/lib/birth-time-guided-preview"; import { defaultGuidedJyotishTopics, generalGuidedJyotishTopics } from "@/lib/guided-jyotish-topics"; +import { normalizeConsultationDomain } from "@/lib/consultation-domain-registry"; import { keepFocusWithin } from "@/lib/focus-trap"; import { BALANCE_SYNC_KEY, @@ -726,7 +727,7 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null return [{ id: session.id, title: typeof session.title === "string" ? session.title.slice(0, 36) : "新对话", - theme: session.theme === "career" || session.theme === "marriage" || session.theme === "timing" ? session.theme : "general", + theme: normalizeConsultationDomain(session.theme) ?? "general", modelId: selection.modelId, messages, sessionType: session.session_type === "birth_time_rectification" diff --git a/frontend/src/components/personal-report/generate-personal-report-button.tsx b/frontend/src/components/personal-report/generate-personal-report-button.tsx index 7fc8ce42..7c531e17 100644 --- a/frontend/src/components/personal-report/generate-personal-report-button.tsx +++ b/frontend/src/components/personal-report/generate-personal-report-button.tsx @@ -195,7 +195,7 @@ export function GeneratePersonalReportButton({ className="inline-flex min-h-11 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg border border-border bg-canvas px-3 py-1.5 text-sm text-ink transition-colors hover:bg-canvas-muted disabled:pointer-events-none disabled:opacity-50" onClick={() => void handleGenerate()} disabled={submitting} - title="根据已确认的个人出生资料生成完整报告" + title="根据已采用或已确认的个人出生资料生成完整报告" >