diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 32c103fc..5956fde5 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4617,7 +4617,7 @@ - 防复发:生时纠正用户面不得再渲染 CompletedActivityReceipt;分盘句来自公开 executed methods;宫位表来自 decision receipt,缺表不得由模型编造。 - 相关记录:BUG-179、BUG-181、BUG-304 - 复发自:BUG-181(完成凭证成为用户主结果后,分盘和宫位没有独立正文位置) -- 修复版本:待提交 +- 修复版本:`c8af18e9` ## BUG-308 | 咨询重跑改了草稿回填条件后,源码合同仍切旧 if,staging 质量门 1807/1808 @@ -4633,4 +4633,20 @@ - 防复发:切 `send()` 失败回填不得再用无 `restoreOnFailure` 的旧 if。源码合同的起止标记必须随被切代码一起改,否则质量门会把无关提交打红。 - 相关记录:BUG-249、BUG-306 - 复发自:BUG-306(加了 `restoreOnFailure` 而未更新草稿隔离切片) +- 修复版本:`d434aa48` + +## BUG-309 | Docker `npm run build` 因会话 schema 与动态 select 类型检查失败 + +- 状态:resolved +- 首次发现:2026-08-19 +- 最近更新:2026-08-19 +- 影响面:`deploy/railway-web.Dockerfile` 的 `RUN npm run build`、`POST /api/sessions`、`POST /api/daily-starlanguage`、`POST /api/synastry` +- 用户现象:staging publish 在 web 镜像构建失败。BuildKit 日志末尾是 `skill-package-registry.ts` 的 Import traces(动态文件访问追踪警告),真正失败是 `Failed to type check`。 +- 触发条件:向 `staging` 推送后走 publish 镜像构建。push 上的 validate 跳过 `npm run build`,因此质量门绿、镜像红。 +- 根因:两处互不相关的 TypeScript 收口。`limitTranscriptSize()` 的泛型下界是 `{ messages: { text: string }[] }`,`.superRefine()` 之后推断输出被收成这个下界,`parsed.data` 不再有 `id`,也丢失消息上的 receipt 字段。每日星语与合盘把出生列 `.join(",")` 成普通 `string` 再交给 supabase-js,`select` 结果变成 `GenericStringError`,无法传入 `AccountBirthRow`。 +- 修复:`limitTranscriptSize` 按输入 schema 的 `Output` 原样返回 `z.ZodType`。出生列改为共享字面量 `ACCOUNT_BIRTH_SELECT`,`globalBirthProfileFromAccountRow` 接受 `unknown` 再收成账户行。 +- 验证:`npx tsc --noEmit` 无错误(修复前 3 个 route + 2 个测试文件)。`tsx --test tests/chat-session-write.test.ts tests/server-owned-birth-profile.test.ts tests/daily-starlanguage.test.ts tests/chart-library-other-profile.test.ts` 36/36。 +- 防复发:创建会话 schema 必须保留 `id`;出生资料 `select` 不得用 `.join(",")` 得到非字面量字符串。staging push 的 validate 不跑 `next build`,类型回归只在 publish Docker 暴露。 +- 相关记录:BUG-261、BUG-308 +- 复发自:无 - 修复版本:待提交 diff --git a/frontend/src/app/api/daily-starlanguage/route.ts b/frontend/src/app/api/daily-starlanguage/route.ts index 3ecf45e8..ab0608ca 100644 --- a/frontend/src/app/api/daily-starlanguage/route.ts +++ b/frontend/src/app/api/daily-starlanguage/route.ts @@ -13,7 +13,7 @@ import { import { dailyProfilePayload } from "@/lib/global-birth-payloads"; import { loadLanguageModelCatalog } from "@/lib/model-catalog"; import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; -import { globalBirthProfileFromAccountRow } from "@/lib/server-owned-birth-profile"; +import { ACCOUNT_BIRTH_SELECT, globalBirthProfileFromAccountRow } from "@/lib/server-owned-birth-profile"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { getDailyStarlanguageAgent } from "@/mastra"; @@ -22,22 +22,6 @@ export const maxDuration = 20; type Profile = ReturnType; type BirthPayload = NonNullable>>; -const accountBirthColumns = [ - "name", - "birth_date", - "reported_birth_time", - "active_birth_time", - "birth_time", - "birth_time_status", - "country_code", - "province_code", - "city_code", - "district_code", - "latitude", - "longitude", - "timezone_offset", - "timezone_id", -].join(","); type CardSource = "engine_evidence" | "agent"; type CacheEntry = { readonly day: string; readonly card: DailyStarlanguageCard; readonly source: CardSource }; type Generated = @@ -192,7 +176,7 @@ export async function POST(request: Request) { const { data: row, error } = await supabase .from("profiles") - .select(accountBirthColumns) + .select(ACCOUNT_BIRTH_SELECT) .eq("id", user.id) .maybeSingle(); if (error || !row) return unavailable("birth_profile_incomplete"); diff --git a/frontend/src/app/api/synastry/route.ts b/frontend/src/app/api/synastry/route.ts index fef96a90..9076ce84 100644 --- a/frontend/src/app/api/synastry/route.ts +++ b/frontend/src/app/api/synastry/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { isProductEnabled } from "@/lib/product-access"; import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; import { + ACCOUNT_BIRTH_SELECT, globalBirthProfileFromAccountRow, globalBirthProfileFromStoredChart, } from "@/lib/server-owned-birth-profile"; @@ -10,22 +11,6 @@ import { createServerSupabaseClient } from "@/lib/supabase/server"; import { synastryBirthPayload } from "@/lib/global-birth-payloads"; const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; -const accountBirthColumns = [ - "name", - "birth_date", - "reported_birth_time", - "active_birth_time", - "birth_time", - "birth_time_status", - "country_code", - "province_code", - "city_code", - "district_code", - "latitude", - "longitude", - "timezone_offset", - "timezone_id", -].join(","); const synastryRequestSchema = z.object({ partnerChartProfileId: z.string().uuid(), relationshipType: z.enum(["romance", "business", "family", "general"]).optional(), @@ -168,7 +153,7 @@ export async function POST(request: Request) { const { data: selfRow, error: selfError } = await supabase .from("profiles") - .select(accountBirthColumns) + .select(ACCOUNT_BIRTH_SELECT) .eq("id", user.id) .maybeSingle(); if (selfError || !selfRow) { diff --git a/frontend/src/lib/chat-session-write-contract.ts b/frontend/src/lib/chat-session-write-contract.ts index a0f060f1..0f2264bf 100644 --- a/frontend/src/lib/chat-session-write-contract.ts +++ b/frontend/src/lib/chat-session-write-contract.ts @@ -34,7 +34,9 @@ const chatSessionWriteObjectSchema = z.object({ updated_at: z.string().datetime(), }).strict(); -function limitTranscriptSize }>>(schema: Schema) { +function limitTranscriptSize }>( + schema: z.ZodType, +): z.ZodType { return schema.superRefine((value, context) => { const totalChars = value.messages.reduce((sum, message) => sum + message.text.length, 0); if (totalChars > CHAT_SESSION_MAX_TOTAL_MESSAGE_CHARS) { diff --git a/frontend/src/lib/server-owned-birth-profile.ts b/frontend/src/lib/server-owned-birth-profile.ts index f5c56e04..24def083 100644 --- a/frontend/src/lib/server-owned-birth-profile.ts +++ b/frontend/src/lib/server-owned-birth-profile.ts @@ -2,6 +2,9 @@ import type { GlobalBirthProfile } from "./global-birth-payloads.ts"; const usableActiveStatuses = new Set(["accepted", "confirmed"]); +export const ACCOUNT_BIRTH_SELECT = + "name,birth_date,reported_birth_time,active_birth_time,birth_time,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id" as const; + export type AccountBirthRow = Readonly<{ name?: unknown; birth_date?: unknown; @@ -19,6 +22,11 @@ export type AccountBirthRow = Readonly<{ timezone_id?: unknown; }>; +function asAccountBirthRow(row: unknown): AccountBirthRow { + if (!row || typeof row !== "object" || Array.isArray(row)) return {}; + return row as AccountBirthRow; +} + function text(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); @@ -60,22 +68,23 @@ function selectedClock(row: AccountBirthRow): string | undefined { return reported ?? active; } -export function globalBirthProfileFromAccountRow(row: AccountBirthRow): GlobalBirthProfile & { +export function globalBirthProfileFromAccountRow(row: unknown): GlobalBirthProfile & { birthTimeStatus?: string; } { + const accountRow = asAccountBirthRow(row); return { - name: text(row.name), - date: calendarDate(row.birth_date), - time: selectedClock(row), - countryCode: text(row.country_code), - provinceCode: text(row.province_code), - cityCode: text(row.city_code), - districtCode: text(row.district_code), - latitude: finiteNumber(row.latitude) ?? null, - longitude: finiteNumber(row.longitude) ?? null, - timezoneOffset: finiteNumber(row.timezone_offset) ?? null, - timezoneId: text(row.timezone_id), - birthTimeStatus: text(row.birth_time_status), + name: text(accountRow.name), + date: calendarDate(accountRow.birth_date), + time: selectedClock(accountRow), + countryCode: text(accountRow.country_code), + provinceCode: text(accountRow.province_code), + cityCode: text(accountRow.city_code), + districtCode: text(accountRow.district_code), + latitude: finiteNumber(accountRow.latitude) ?? null, + longitude: finiteNumber(accountRow.longitude) ?? null, + timezoneOffset: finiteNumber(accountRow.timezone_offset) ?? null, + timezoneId: text(accountRow.timezone_id), + birthTimeStatus: text(accountRow.birth_time_status), }; } diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index a007f9ed..0e7d87d7 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts"; +import { chatSessionCreateSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts"; const sessionId = "11111111-1111-4111-8111-111111111111"; const values = { @@ -15,6 +15,17 @@ const values = { } satisfies ChatSessionWrite; +test("create schema keeps the client-generated session id after transcript limits", () => { + const parsed = chatSessionCreateSchema.parse({ + id: sessionId, + ...values, + }); + const id: string = parsed.id; + assert.equal(id, sessionId); + assert.equal(parsed.title, values.title); + assert.equal(parsed.messages[0]?.text, "你好"); +}); + test("chat session schema preserves the safe agent execution receipt", () => { const receipt = { runId: "run-1", @@ -99,6 +110,7 @@ test("session API owns create and update while answer UI keeps sync failures out const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const collectionRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8"); const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8"); + const contract = readFileSync(new URL("../src/lib/chat-session-write-contract.ts", import.meta.url), "utf8"); assert.match(page, /writeChatSession\(session\.id, values, mode\)/); assert.doesNotMatch(page, /云端同步失败.*回答仍保留在当前页面/); @@ -109,6 +121,9 @@ test("session API owns create and update while answer UI keeps sync failures out assert.match(itemRoute, /readChatSessionJson/); assert.match(collectionRoute, /ChatSessionBodyTooLargeError/); assert.match(itemRoute, /ChatSessionBodyTooLargeError/); + assert.match(collectionRoute, /const \{ id, \.\.\.values \} = parsed\.data/); + assert.match(contract, /function limitTranscriptSize \}>/); + assert.match(contract, /\): z\.ZodType \{/); }); diff --git a/frontend/tests/server-owned-birth-profile.test.ts b/frontend/tests/server-owned-birth-profile.test.ts index ad673f1e..58a58f8c 100644 --- a/frontend/tests/server-owned-birth-profile.test.ts +++ b/frontend/tests/server-owned-birth-profile.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { readFileSync } from "node:fs"; import { + ACCOUNT_BIRTH_SELECT, globalBirthProfileFromAccountRow, globalBirthProfileFromStoredChart, } from "../src/lib/server-owned-birth-profile.ts"; @@ -74,3 +76,18 @@ test("a stored chart without a clock cannot be used for synastry", () => { assert.equal(globalBirthProfileFromStoredChart({ date: "1990-01-01", name: "对方" }), null); assert.equal(globalBirthProfileFromStoredChart(null), null); }); + +test("daily and synastry routes share a literal account-birth select string", () => { + const daily = readFileSync(new URL("../src/app/api/daily-starlanguage/route.ts", import.meta.url), "utf8"); + const synastry = readFileSync(new URL("../src/app/api/synastry/route.ts", import.meta.url), "utf8"); + assert.equal( + ACCOUNT_BIRTH_SELECT, + "name,birth_date,reported_birth_time,active_birth_time,birth_time,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id", + ); + assert.match(daily, /select\(ACCOUNT_BIRTH_SELECT\)/); + assert.match(synastry, /select\(ACCOUNT_BIRTH_SELECT\)/); + assert.doesNotMatch(daily, /accountBirthColumns/); + assert.doesNotMatch(synastry, /accountBirthColumns/); + assert.doesNotMatch(daily, /\.join\(",\"\)/); + assert.doesNotMatch(synastry, /\.join\(",\"\)/); +});