fix(qa): 修 QA C1/H1/H2——写章/规则缺项目校验 + 立项向导字段覆盖

C1 (CRITICAL) stream_draft:对不存在 project 流式写章先 fail-fast 404,
  否则非法 id 静默烧一次付费/限流 LLM 调用并返 200。在触网关前查 project_repo.get。
H2 (HIGH) create_rule:给不存在 project 加规则原 FK 违例逃逸成 500 → 改为入库前
  校验项目存在返 404(仿 chain/_require_project)。
H1 (HIGH) ProjectWizard:第3步「立意」与第4步「主角/金手指」原共用 form.premise
  互相覆盖丢数据 → 新增独立 form.protagonist,toCreateRequest 合并两段进 premise
  (M1 projects 表仍只有 premise,不编造 API)。

回归测试:
- test_projects.py:stream_draft 不存在 project → 404 且网关零调用;已有 draft
  用例改 seed 真项目。
- test_rules.py:create_rule 不存在 project → 404 不写库;已有用例 seed 真项目。
- wizard.test.ts:premise+protagonist 合并不互相覆盖(2 例)。
门禁绿:ruff/format clean · mypy 210 · pytest 749 · 前端 tsc/lint/vitest 干净。
This commit is contained in:
Yaojia Wang
2026-06-24 17:17:35 +02:00
parent a4ef250fc9
commit 2fe3bedfba
13 changed files with 688 additions and 27 deletions

1
apps/web/.gitignore vendored
View File

@@ -2,3 +2,4 @@
/.next
/lib/api/openapi.json
next-env.d.ts
.gstack/

View File

@@ -274,7 +274,8 @@ function StepPremise({ form, update }: StepProps) {
}
function StepProtagonist({ form, update }: StepProps) {
// M1 projects 表无独立主角/金手指字段;先并入立意/总纲文本,避免编造 API
// M1 projects 表无独立主角/金手指字段;提交时由 toCreateRequest 合并进 premise与立意各占一段
// 必须绑定独立的 form.protagonist不能复用 form.premise否则与第 3 步立意互相覆盖QA H1
return (
<div>
<p className="mb-3 text-sm text-ink-soft">
@@ -283,8 +284,8 @@ function StepProtagonist({ form, update }: StepProps) {
<Field label="主角 / 金手指概要">
<textarea
className={`${inputCls} h-28 resize-none`}
value={form.premise}
onChange={(e) => update({ premise: e.target.value })}
value={form.protagonist}
onChange={(e) => update({ protagonist: e.target.value })}
placeholder="主角设定、金手指来源与限制……"
/>
</Field>

View File

@@ -44,6 +44,7 @@ describe("toCreateRequest", () => {
sellingPoints: ["逆袭", "系统流"],
structure: "三幕",
premise: " ",
protagonist: "",
theme: "抗争",
};
expect(toCreateRequest(form)).toEqual({
@@ -56,6 +57,30 @@ describe("toCreateRequest", () => {
structure: "三幕",
});
});
// QA H1 回归立意premise与主角/金手指protagonist是两个独立输入
// 不能互相覆盖——提交时各占一段合并进 premise。
it("merges premise and protagonist into premise without overwriting", () => {
const form: WizardForm = {
...emptyWizardForm,
title: "书",
premise: "凡人逆袭的代价",
protagonist: "主角林川,金手指=吞噬术,限制:每次吞噬折寿",
};
const req = toCreateRequest(form);
expect(req.premise).toBe(
"凡人逆袭的代价\n\n主角/金手指:主角林川,金手指=吞噬术,限制:每次吞噬折寿",
);
});
it("keeps protagonist alone when premise is empty", () => {
const form: WizardForm = {
...emptyWizardForm,
title: "书",
protagonist: "主角设定",
};
expect(toCreateRequest(form).premise).toBe("主角/金手指:主角设定");
});
});
// wizard→create 流程:归一后的请求体经 mock 客户端提交,返回新建项目 id。

View File

@@ -8,6 +8,7 @@ export interface WizardForm {
sellingPoints: string[];
structure: string;
premise: string;
protagonist: string;
theme: string;
}
@@ -20,6 +21,7 @@ export const emptyWizardForm: WizardForm = {
sellingPoints: [],
structure: "",
premise: "",
protagonist: "",
theme: "",
};
@@ -50,11 +52,17 @@ export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
const v = s.trim();
return v.length > 0 ? v : null;
};
// 立意premise与主角/金手指protagonist是两个独立输入但 M1 projects 表只有
// premise 一个字段 → 合并进 premise二者各占一段互不覆盖QA H1此前共用同一字段
const premise =
[trim(form.premise), form.protagonist.trim() ? `主角/金手指:${form.protagonist.trim()}` : null]
.filter((s): s is string => s !== null)
.join("\n\n") || null;
return {
title: form.title.trim(),
genre: trim(form.genre),
logline: trim(form.logline),
premise: trim(form.premise),
premise,
theme: trim(form.theme),
selling_points: form.sellingPoints,
structure: trim(form.structure),

File diff suppressed because one or more lines are too long