feat(v2.2): 建立分区关系表和AppData迁移预演

This commit is contained in:
Script Generator
2026-07-03 10:23:18 +08:00
parent 0d9e6fee29
commit 5a90923031
16 changed files with 2440 additions and 8 deletions

View File

@@ -0,0 +1,414 @@
-- V2.2 domain schema foundation.
-- High-growth business tables are partitioned from the start so primary,
-- unique, and foreign key shapes do not need to be redesigned after data grows.
-- Preserve the early prototype requirement table before replacing it with the
-- V2.2 partitioned requirements pool table.
ALTER TABLE "requirements" RENAME TO "requirements_legacy";
ALTER TABLE "requirements_legacy" RENAME CONSTRAINT "requirements_pkey" TO "requirements_legacy_pkey";
ALTER TABLE "requirements_legacy" RENAME CONSTRAINT "requirements_product_id_fkey" TO "requirements_legacy_product_id_fkey";
ALTER TABLE "requirements_legacy" RENAME CONSTRAINT "requirements_creator_id_fkey" TO "requirements_legacy_creator_id_fkey";
ALTER TABLE "versions" ADD COLUMN "project_id" TEXT;
ALTER TABLE "versions" ADD CONSTRAINT "versions_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "versions_product_id_project_id_idx" ON "versions"("product_id", "project_id");
CREATE TABLE "task_categories" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT,
"group" TEXT NOT NULL,
"is_system" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "task_categories_pkey" PRIMARY KEY ("id"),
CONSTRAINT "task_categories_group_name_key" UNIQUE ("group", "name")
);
CREATE UNIQUE INDEX "task_categories_group_code_key" ON "task_categories"("group", "code") WHERE "code" IS NOT NULL;
CREATE TABLE "requirements" (
"id" TEXT NOT NULL,
"product_id" TEXT NOT NULL,
"project_id" TEXT,
"version_id" TEXT,
"code" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'draft',
"priority" INTEGER NOT NULL DEFAULT 0,
"type" TEXT,
"source_type" TEXT,
"source_target" TEXT,
"platform" TEXT,
"creator_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "requirements_pkey" PRIMARY KEY ("id", "product_id"),
CONSTRAINT "requirements_product_id_code_key" UNIQUE ("product_id", "code"),
CONSTRAINT "requirements_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "requirements_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "requirements_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "requirements_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE
) PARTITION BY HASH ("product_id");
CREATE TABLE "requirements_p00" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 0);
CREATE TABLE "requirements_p01" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 1);
CREATE TABLE "requirements_p02" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 2);
CREATE TABLE "requirements_p03" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 3);
CREATE TABLE "requirements_p04" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 4);
CREATE TABLE "requirements_p05" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 5);
CREATE TABLE "requirements_p06" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 6);
CREATE TABLE "requirements_p07" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 7);
CREATE TABLE "requirements_p08" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 8);
CREATE TABLE "requirements_p09" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 9);
CREATE TABLE "requirements_p10" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 10);
CREATE TABLE "requirements_p11" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 11);
CREATE TABLE "requirements_p12" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 12);
CREATE TABLE "requirements_p13" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 13);
CREATE TABLE "requirements_p14" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 14);
CREATE TABLE "requirements_p15" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 15);
CREATE INDEX "requirements_product_project_status_created_at_idx" ON "requirements"("product_id", "project_id", "status", "created_at" DESC);
CREATE INDEX "requirements_version_status_created_at_idx" ON "requirements"("version_id", "status", "created_at" DESC);
CREATE INDEX "requirements_creator_created_at_idx" ON "requirements"("creator_id", "created_at" DESC);
CREATE TABLE "version_plans" (
"id" TEXT NOT NULL,
"version_id" TEXT NOT NULL,
"product_id" TEXT NOT NULL,
"project_id" TEXT,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"owner_id" TEXT,
"expected_start_at" TIMESTAMP(3),
"expected_end_at" TIMESTAMP(3),
"actual_start_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"result_url" TEXT,
"requirement_coverage" JSONB NOT NULL DEFAULT '[]',
"logs" JSONB NOT NULL DEFAULT '[]',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "version_plans_pkey" PRIMARY KEY ("id"),
CONSTRAINT "version_plans_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "version_plans_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "version_plans_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "version_plans_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE INDEX "version_plans_version_type_status_idx" ON "version_plans"("version_id", "type", "status");
CREATE INDEX "version_plans_owner_status_end_idx" ON "version_plans"("owner_id", "status", "expected_end_at") WHERE "status" <> 'completed';
CREATE TABLE "dev_tasks" (
"id" TEXT NOT NULL,
"version_id" TEXT NOT NULL,
"product_id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"requirement_id" TEXT,
"requirement_product_id" TEXT,
"category_id" TEXT,
"code" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'todo',
"priority" INTEGER NOT NULL DEFAULT 0,
"assignee_id" TEXT,
"creator_id" TEXT,
"is_blocked" BOOLEAN NOT NULL DEFAULT false,
"block_reason" TEXT,
"expected_start_at" TIMESTAMP(3),
"expected_end_at" TIMESTAMP(3),
"start_date" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"estimate_hours" DOUBLE PRECISION,
"ai_estimate_hours" DOUBLE PRECISION,
"references" JSONB NOT NULL DEFAULT '[]',
"ai_draft" BOOLEAN NOT NULL DEFAULT false,
"ai_draft_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "dev_tasks_pkey" PRIMARY KEY ("id", "version_id"),
CONSTRAINT "dev_tasks_version_id_code_key" UNIQUE ("version_id", "code"),
CONSTRAINT "dev_tasks_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "dev_tasks_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "dev_tasks_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "dev_tasks_requirement_fkey" FOREIGN KEY ("requirement_id", "requirement_product_id") REFERENCES "requirements"("id", "product_id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "dev_tasks_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "task_categories"("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "dev_tasks_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "dev_tasks_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE
) PARTITION BY HASH ("version_id");
CREATE TABLE "dev_tasks_p00" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 0);
CREATE TABLE "dev_tasks_p01" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 1);
CREATE TABLE "dev_tasks_p02" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 2);
CREATE TABLE "dev_tasks_p03" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 3);
CREATE TABLE "dev_tasks_p04" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 4);
CREATE TABLE "dev_tasks_p05" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 5);
CREATE TABLE "dev_tasks_p06" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 6);
CREATE TABLE "dev_tasks_p07" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 7);
CREATE TABLE "dev_tasks_p08" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 8);
CREATE TABLE "dev_tasks_p09" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 9);
CREATE TABLE "dev_tasks_p10" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 10);
CREATE TABLE "dev_tasks_p11" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 11);
CREATE TABLE "dev_tasks_p12" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 12);
CREATE TABLE "dev_tasks_p13" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 13);
CREATE TABLE "dev_tasks_p14" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 14);
CREATE TABLE "dev_tasks_p15" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 15);
CREATE INDEX "dev_tasks_version_status_updated_at_idx" ON "dev_tasks"("version_id", "status", "updated_at" DESC);
CREATE INDEX "dev_tasks_requirement_id_idx" ON "dev_tasks"("requirement_id");
CREATE INDEX "dev_tasks_assignee_unfinished_idx" ON "dev_tasks"("assignee_id", "status", "updated_at" DESC) WHERE "status" <> 'submitted';
CREATE TABLE "test_cases" (
"id" TEXT NOT NULL,
"version_id" TEXT NOT NULL,
"product_id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"requirement_id" TEXT,
"requirement_product_id" TEXT,
"category_id" TEXT,
"code" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'pending',
"round_no" INTEGER NOT NULL DEFAULT 1,
"priority" INTEGER NOT NULL DEFAULT 0,
"assignee_id" TEXT,
"creator_id" TEXT,
"planned_test_at" TIMESTAMP(3),
"planned_end_at" TIMESTAMP(3),
"started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"estimate_hours" DOUBLE PRECISION,
"ai_estimate_hours" DOUBLE PRECISION,
"references" JSONB NOT NULL DEFAULT '[]',
"ai_draft" BOOLEAN NOT NULL DEFAULT false,
"ai_draft_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "test_cases_pkey" PRIMARY KEY ("id", "version_id"),
CONSTRAINT "test_cases_version_id_code_key" UNIQUE ("version_id", "code"),
CONSTRAINT "test_cases_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "test_cases_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "test_cases_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "test_cases_requirement_fkey" FOREIGN KEY ("requirement_id", "requirement_product_id") REFERENCES "requirements"("id", "product_id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "test_cases_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "task_categories"("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "test_cases_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "test_cases_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE
) PARTITION BY HASH ("version_id");
CREATE TABLE "test_cases_p00" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 0);
CREATE TABLE "test_cases_p01" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 1);
CREATE TABLE "test_cases_p02" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 2);
CREATE TABLE "test_cases_p03" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 3);
CREATE TABLE "test_cases_p04" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 4);
CREATE TABLE "test_cases_p05" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 5);
CREATE TABLE "test_cases_p06" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 6);
CREATE TABLE "test_cases_p07" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 7);
CREATE TABLE "test_cases_p08" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 8);
CREATE TABLE "test_cases_p09" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 9);
CREATE TABLE "test_cases_p10" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 10);
CREATE TABLE "test_cases_p11" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 11);
CREATE TABLE "test_cases_p12" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 12);
CREATE TABLE "test_cases_p13" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 13);
CREATE TABLE "test_cases_p14" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 14);
CREATE TABLE "test_cases_p15" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 15);
CREATE INDEX "test_cases_version_round_status_updated_at_idx" ON "test_cases"("version_id", "round_no", "status", "updated_at" DESC);
CREATE INDEX "test_cases_requirement_id_idx" ON "test_cases"("requirement_id");
CREATE INDEX "test_cases_assignee_unfinished_idx" ON "test_cases"("assignee_id", "status", "updated_at" DESC) WHERE "status" NOT IN ('passed', 'failed', 'blocked');
CREATE TABLE "bugs" (
"id" TEXT NOT NULL,
"version_id" TEXT NOT NULL,
"product_id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"test_case_id" TEXT,
"test_case_version_id" TEXT,
"code" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'open',
"severity" TEXT NOT NULL DEFAULT 'normal',
"priority" INTEGER NOT NULL DEFAULT 0,
"assignee_id" TEXT,
"reporter_id" TEXT,
"planned_fix_at" TIMESTAMP(3),
"resolved_at" TIMESTAMP(3),
"closed_at" TIMESTAMP(3),
"resolution" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "bugs_pkey" PRIMARY KEY ("id", "version_id"),
CONSTRAINT "bugs_version_id_code_key" UNIQUE ("version_id", "code"),
CONSTRAINT "bugs_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "bugs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "bugs_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "bugs_test_case_fkey" FOREIGN KEY ("test_case_id", "test_case_version_id") REFERENCES "test_cases"("id", "version_id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "bugs_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "bugs_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE
) PARTITION BY HASH ("version_id");
CREATE TABLE "bugs_p00" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 0);
CREATE TABLE "bugs_p01" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 1);
CREATE TABLE "bugs_p02" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 2);
CREATE TABLE "bugs_p03" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 3);
CREATE TABLE "bugs_p04" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 4);
CREATE TABLE "bugs_p05" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 5);
CREATE TABLE "bugs_p06" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 6);
CREATE TABLE "bugs_p07" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 7);
CREATE TABLE "bugs_p08" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 8);
CREATE TABLE "bugs_p09" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 9);
CREATE TABLE "bugs_p10" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 10);
CREATE TABLE "bugs_p11" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 11);
CREATE TABLE "bugs_p12" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 12);
CREATE TABLE "bugs_p13" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 13);
CREATE TABLE "bugs_p14" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 14);
CREATE TABLE "bugs_p15" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 15);
CREATE INDEX "bugs_version_status_severity_updated_at_idx" ON "bugs"("version_id", "status", "severity", "updated_at" DESC);
CREATE INDEX "bugs_test_case_id_idx" ON "bugs"("test_case_id");
CREATE INDEX "bugs_assignee_open_idx" ON "bugs"("assignee_id", "status", "updated_at" DESC) WHERE "status" IN ('open', 'fixing', 'fixed', 'verifying');
CREATE TABLE "work_activities" (
"id" TEXT NOT NULL,
"version_id" TEXT,
"product_id" TEXT,
"project_id" TEXT,
"actor_id" TEXT,
"actor_name" TEXT NOT NULL DEFAULT '',
"source_type" TEXT NOT NULL,
"source_id" TEXT NOT NULL,
"source_version_id" TEXT,
"action" TEXT NOT NULL,
"title" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"occurred_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "work_activities_pkey" PRIMARY KEY ("id", "created_at")
) PARTITION BY RANGE ("created_at");
CREATE TABLE "work_activities_default" PARTITION OF "work_activities" DEFAULT;
CREATE INDEX "work_activities_version_created_at_idx" ON "work_activities"("version_id", "created_at" DESC);
CREATE INDEX "work_activities_actor_created_at_idx" ON "work_activities"("actor_id", "created_at" DESC);
CREATE TABLE "task_worklogs" (
"id" TEXT NOT NULL,
"version_id" TEXT,
"product_id" TEXT,
"project_id" TEXT,
"user_id" TEXT,
"source_type" TEXT NOT NULL,
"source_id" TEXT NOT NULL,
"source_version_id" TEXT,
"work_date" DATE NOT NULL,
"hours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"content" TEXT NOT NULL DEFAULT '',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "task_worklogs_pkey" PRIMARY KEY ("id", "created_at")
) PARTITION BY RANGE ("created_at");
CREATE TABLE "task_worklogs_default" PARTITION OF "task_worklogs" DEFAULT;
CREATE INDEX "task_worklogs_user_date_idx" ON "task_worklogs"("user_id", "work_date" DESC);
CREATE INDEX "task_worklogs_version_date_idx" ON "task_worklogs"("version_id", "work_date" DESC);
CREATE TABLE "overtime_records" (
"id" TEXT NOT NULL,
"product_id" TEXT,
"project_id" TEXT,
"version_id" TEXT,
"user_id" TEXT,
"reason" TEXT NOT NULL DEFAULT '',
"start_at" TIMESTAMP(3) NOT NULL,
"end_at" TIMESTAMP(3) NOT NULL,
"hours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "overtime_records_pkey" PRIMARY KEY ("id", "created_at")
) PARTITION BY RANGE ("created_at");
CREATE TABLE "overtime_records_default" PARTITION OF "overtime_records" DEFAULT;
CREATE INDEX "overtime_records_user_created_at_idx" ON "overtime_records"("user_id", "created_at" DESC);
CREATE INDEX "overtime_records_project_created_at_idx" ON "overtime_records"("project_id", "created_at" DESC);
CREATE TABLE "xiaobao_risk_snapshots" (
"id" TEXT NOT NULL,
"version_id" TEXT NOT NULL,
"snapshot_date" DATE NOT NULL,
"risk_level" TEXT NOT NULL,
"risk_score" INTEGER NOT NULL DEFAULT 0,
"risk_signature" TEXT NOT NULL,
"snapshot" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "xiaobao_risk_snapshots_pkey" PRIMARY KEY ("id", "created_at"),
CONSTRAINT "xiaobao_risk_snapshots_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE
) PARTITION BY RANGE ("created_at");
CREATE TABLE "xiaobao_risk_snapshots_default" PARTITION OF "xiaobao_risk_snapshots" DEFAULT;
CREATE INDEX "xiaobao_risk_snapshots_version_created_at_idx" ON "xiaobao_risk_snapshots"("version_id", "created_at" DESC);
CREATE INDEX "xiaobao_risk_snapshots_date_idx" ON "xiaobao_risk_snapshots"("snapshot_date" DESC);
CREATE TABLE "xiaobao_risk_insights" (
"id" TEXT NOT NULL,
"version_id" TEXT NOT NULL,
"risk_signature" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'generated',
"insight" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "xiaobao_risk_insights_pkey" PRIMARY KEY ("id", "created_at"),
CONSTRAINT "xiaobao_risk_insights_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE
) PARTITION BY RANGE ("created_at");
CREATE TABLE "xiaobao_risk_insights_default" PARTITION OF "xiaobao_risk_insights" DEFAULT;
CREATE INDEX "xiaobao_risk_insights_version_signature_idx" ON "xiaobao_risk_insights"("version_id", "risk_signature", "created_at" DESC);
CREATE TABLE "ai_logs" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"model" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"status" TEXT NOT NULL,
"prompt_tokens" INTEGER NOT NULL DEFAULT 0,
"completion_tokens" INTEGER NOT NULL DEFAULT 0,
"duration_ms" INTEGER NOT NULL DEFAULT 0,
"error_message" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_logs_pkey" PRIMARY KEY ("id", "created_at")
) PARTITION BY RANGE ("created_at");
CREATE TABLE "ai_logs_default" PARTITION OF "ai_logs" DEFAULT;
CREATE INDEX "ai_logs_operation_created_at_idx" ON "ai_logs"("operation", "created_at" DESC);
CREATE INDEX "ai_logs_status_created_at_idx" ON "ai_logs"("status", "created_at" DESC);
CREATE TABLE "xiaobao_risk_summaries" (
"version_id" TEXT NOT NULL,
"risk_level" TEXT NOT NULL,
"risk_score" INTEGER NOT NULL DEFAULT 0,
"confidence" INTEGER NOT NULL DEFAULT 0,
"forecast_release_date" TIMESTAMP(3),
"risk_signature" TEXT NOT NULL,
"summary" JSONB NOT NULL,
"dirty" BOOLEAN NOT NULL DEFAULT false,
"recomputed_at" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "xiaobao_risk_summaries_pkey" PRIMARY KEY ("version_id"),
CONSTRAINT "xiaobao_risk_summaries_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE
);

View File

@@ -49,6 +49,7 @@ model Project {
product Product @relation(fields: [productId], references: [id]) product Product @relation(fields: [productId], references: [id])
sprints Sprint[] sprints Sprint[]
tasks Task[] tasks Task[]
versions Version[]
members ProjectMember[] members ProjectMember[]
@@map("projects") @@map("projects")
@@ -72,6 +73,7 @@ model Sprint {
model Version { model Version {
id String @id @default(cuid()) id String @id @default(cuid())
productId String @map("product_id") productId String @map("product_id")
projectId String? @map("project_id")
name String name String
description String @default("") description String @default("")
releaseDate DateTime? @map("release_date") releaseDate DateTime? @map("release_date")
@@ -79,25 +81,35 @@ model Version {
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
product Product @relation(fields: [productId], references: [id]) product Product @relation(fields: [productId], references: [id])
project Project? @relation(fields: [projectId], references: [id])
tasks Task[] tasks Task[]
@@map("versions") @@map("versions")
} }
model Requirement { model Requirement {
id String @id @default(cuid()) id String @default(cuid())
productId String @map("product_id") productId String @map("product_id")
projectId String? @map("project_id")
versionId String? @map("version_id")
code String
title String title String
description String @default("") description String @default("")
status String @default("draft") status String @default("draft")
priority Int @default(0) priority Int @default(0)
creatorId String @map("creator_id") type String?
sourceType String? @map("source_type")
sourceTarget String? @map("source_target")
platform String?
creatorId String? @map("creator_id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
product Product @relation(fields: [productId], references: [id]) product Product @relation(fields: [productId], references: [id])
creator User @relation(fields: [creatorId], references: [id]) creator User? @relation(fields: [creatorId], references: [id])
@@id([id, productId])
@@unique([productId, code])
@@map("requirements") @@map("requirements")
} }
@@ -168,6 +180,250 @@ model ProjectMember {
@@map("project_members") @@map("project_members")
} }
model TaskCategory {
id String @id @default(cuid())
name String
code String?
group String
isSystem Boolean @default(false) @map("is_system")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([group, name])
@@map("task_categories")
}
model VersionPlan {
id String @id @default(cuid())
versionId String @map("version_id")
productId String @map("product_id")
projectId String? @map("project_id")
type String
title String
status String @default("pending")
ownerId String? @map("owner_id")
expectedStartAt DateTime? @map("expected_start_at")
expectedEndAt DateTime? @map("expected_end_at")
actualStartAt DateTime? @map("actual_start_at")
completedAt DateTime? @map("completed_at")
resultUrl String? @map("result_url")
requirementCoverage Json @default("[]") @map("requirement_coverage")
logs Json @default("[]")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("version_plans")
}
model DevTask {
id String @default(cuid())
versionId String @map("version_id")
productId String @map("product_id")
projectId String @map("project_id")
requirementId String? @map("requirement_id")
requirementProductId String? @map("requirement_product_id")
categoryId String? @map("category_id")
code String
title String
description String @default("")
status String @default("todo")
priority Int @default(0)
assigneeId String? @map("assignee_id")
creatorId String? @map("creator_id")
isBlocked Boolean @default(false) @map("is_blocked")
blockReason String? @map("block_reason")
expectedStartAt DateTime? @map("expected_start_at")
expectedEndAt DateTime? @map("expected_end_at")
startDate DateTime? @map("start_date")
completedAt DateTime? @map("completed_at")
estimateHours Float? @map("estimate_hours")
aiEstimateHours Float? @map("ai_estimate_hours")
references Json @default("[]")
aiDraft Boolean @default(false) @map("ai_draft")
aiDraftAt DateTime? @map("ai_draft_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([id, versionId])
@@unique([versionId, code])
@@map("dev_tasks")
}
model TestCase {
id String @default(cuid())
versionId String @map("version_id")
productId String @map("product_id")
projectId String @map("project_id")
requirementId String? @map("requirement_id")
requirementProductId String? @map("requirement_product_id")
categoryId String? @map("category_id")
code String
title String
description String @default("")
status String @default("pending")
roundNo Int @default(1) @map("round_no")
priority Int @default(0)
assigneeId String? @map("assignee_id")
creatorId String? @map("creator_id")
plannedTestAt DateTime? @map("planned_test_at")
plannedEndAt DateTime? @map("planned_end_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
estimateHours Float? @map("estimate_hours")
aiEstimateHours Float? @map("ai_estimate_hours")
references Json @default("[]")
aiDraft Boolean @default(false) @map("ai_draft")
aiDraftAt DateTime? @map("ai_draft_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([id, versionId])
@@unique([versionId, code])
@@map("test_cases")
}
model Bug {
id String @default(cuid())
versionId String @map("version_id")
productId String @map("product_id")
projectId String @map("project_id")
testCaseId String? @map("test_case_id")
testCaseVersionId String? @map("test_case_version_id")
code String
title String
description String @default("")
status String @default("open")
severity String @default("normal")
priority Int @default(0)
assigneeId String? @map("assignee_id")
reporterId String? @map("reporter_id")
plannedFixAt DateTime? @map("planned_fix_at")
resolvedAt DateTime? @map("resolved_at")
closedAt DateTime? @map("closed_at")
resolution String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([id, versionId])
@@unique([versionId, code])
@@map("bugs")
}
model WorkActivity {
id String @default(cuid())
versionId String? @map("version_id")
productId String? @map("product_id")
projectId String? @map("project_id")
actorId String? @map("actor_id")
actorName String @default("") @map("actor_name")
sourceType String @map("source_type")
sourceId String @map("source_id")
sourceVersionId String? @map("source_version_id")
action String
title String
metadata Json @default("{}")
occurredAt DateTime @map("occurred_at")
createdAt DateTime @default(now()) @map("created_at")
@@id([id, createdAt])
@@map("work_activities")
}
model TaskWorklog {
id String @default(cuid())
versionId String? @map("version_id")
productId String? @map("product_id")
projectId String? @map("project_id")
userId String? @map("user_id")
sourceType String @map("source_type")
sourceId String @map("source_id")
sourceVersionId String? @map("source_version_id")
workDate DateTime @db.Date @map("work_date")
hours Float @default(0)
content String @default("")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([id, createdAt])
@@map("task_worklogs")
}
model OvertimeRecord {
id String @default(cuid())
productId String? @map("product_id")
projectId String? @map("project_id")
versionId String? @map("version_id")
userId String? @map("user_id")
reason String @default("")
startAt DateTime @map("start_at")
endAt DateTime @map("end_at")
hours Float @default(0)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([id, createdAt])
@@map("overtime_records")
}
model XiaobaoRiskSnapshot {
id String @default(cuid())
versionId String @map("version_id")
snapshotDate DateTime @db.Date @map("snapshot_date")
riskLevel String @map("risk_level")
riskScore Int @default(0) @map("risk_score")
riskSignature String @map("risk_signature")
snapshot Json
createdAt DateTime @default(now()) @map("created_at")
@@id([id, createdAt])
@@map("xiaobao_risk_snapshots")
}
model XiaobaoRiskInsight {
id String @default(cuid())
versionId String @map("version_id")
riskSignature String @map("risk_signature")
status String @default("generated")
insight Json
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([id, createdAt])
@@map("xiaobao_risk_insights")
}
model AiLog {
id String @default(cuid())
provider String
model String
operation String
status String
promptTokens Int @default(0) @map("prompt_tokens")
completionTokens Int @default(0) @map("completion_tokens")
durationMs Int @default(0) @map("duration_ms")
errorMessage String? @map("error_message")
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
@@id([id, createdAt])
@@map("ai_logs")
}
model XiaobaoRiskSummary {
versionId String @id @map("version_id")
riskLevel String @map("risk_level")
riskScore Int @default(0) @map("risk_score")
confidence Int @default(0)
forecastReleaseDate DateTime? @map("forecast_release_date")
riskSignature String @map("risk_signature")
summary Json
dirty Boolean @default(false)
recomputedAt DateTime? @map("recomputed_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("xiaobao_risk_summaries")
}
model AppData { model AppData {
key String @id key String @id
value Json value Json

View File

@@ -0,0 +1,104 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const migrationPath = join(
process.cwd(),
'prisma',
'migrations',
'20260703000000_v22_partitioned_domain_schema',
'migration.sql',
);
function readMigrationSql() {
if (!existsSync(migrationPath)) {
throw new Error(`Missing V2.2 migration: ${migrationPath}`);
}
return readFileSync(migrationPath, 'utf8');
}
function expectPartitionedTable(
sql: string,
table: string,
method: 'HASH' | 'RANGE',
key: string,
primaryKey: string[],
) {
const quotedPrimaryKey = primaryKey.map((column) => `"${column}"`).join(', ');
expect(sql).toMatch(
new RegExp(
`CREATE TABLE "${table}"[\\s\\S]*CONSTRAINT "${table}_pkey" PRIMARY KEY \\(${quotedPrimaryKey}\\)[\\s\\S]*PARTITION BY ${method} \\("${key}"\\);`,
),
);
}
function expectHashPartitions(sql: string, table: string, count: number) {
const matches = sql.match(
new RegExp(`PARTITION OF "${table}" FOR VALUES WITH \\(MODULUS ${count}, REMAINDER`, 'g'),
);
expect(matches).toHaveLength(count);
}
describe('V2.2 partitioned domain schema migration', () => {
it('keeps every hash partitioned business table primary key anchored by the partition key', () => {
const sql = readMigrationSql();
expectPartitionedTable(sql, 'requirements', 'HASH', 'product_id', ['id', 'product_id']);
expectPartitionedTable(sql, 'dev_tasks', 'HASH', 'version_id', ['id', 'version_id']);
expectPartitionedTable(sql, 'test_cases', 'HASH', 'version_id', ['id', 'version_id']);
expectPartitionedTable(sql, 'bugs', 'HASH', 'version_id', ['id', 'version_id']);
expectHashPartitions(sql, 'requirements', 16);
expectHashPartitions(sql, 'dev_tasks', 16);
expectHashPartitions(sql, 'test_cases', 16);
expectHashPartitions(sql, 'bugs', 16);
});
it('includes partition keys in business unique constraints', () => {
const sql = readMigrationSql();
expect(sql).toMatch(/CONSTRAINT "requirements_product_id_code_key" UNIQUE \("product_id", "code"\)/);
expect(sql).toMatch(/CONSTRAINT "dev_tasks_version_id_code_key" UNIQUE \("version_id", "code"\)/);
expect(sql).toMatch(/CONSTRAINT "test_cases_version_id_code_key" UNIQUE \("version_id", "code"\)/);
expect(sql).toMatch(/CONSTRAINT "bugs_version_id_code_key" UNIQUE \("version_id", "code"\)/);
});
it('uses composite foreign keys when a referenced table is partitioned', () => {
const sql = readMigrationSql();
expect(sql).toMatch(
/FOREIGN KEY \("requirement_id", "requirement_product_id"\) REFERENCES "requirements"\("id", "product_id"\)/,
);
expect(sql).toMatch(
/FOREIGN KEY \("test_case_id", "test_case_version_id"\) REFERENCES "test_cases"\("id", "version_id"\)/,
);
});
it('range partitions append-only evidence and snapshot tables by created_at', () => {
const sql = readMigrationSql();
expectPartitionedTable(sql, 'work_activities', 'RANGE', 'created_at', ['id', 'created_at']);
expectPartitionedTable(sql, 'task_worklogs', 'RANGE', 'created_at', ['id', 'created_at']);
expectPartitionedTable(sql, 'overtime_records', 'RANGE', 'created_at', ['id', 'created_at']);
expectPartitionedTable(sql, 'xiaobao_risk_snapshots', 'RANGE', 'created_at', ['id', 'created_at']);
expectPartitionedTable(sql, 'ai_logs', 'RANGE', 'created_at', ['id', 'created_at']);
for (const table of [
'work_activities',
'task_worklogs',
'overtime_records',
'xiaobao_risk_snapshots',
'ai_logs',
]) {
expect(sql).toMatch(new RegExp(`CREATE TABLE "${table}_default" PARTITION OF "${table}" DEFAULT;`));
}
});
it('keeps Xiaobao current summaries as one row per version instead of partitioned history', () => {
const sql = readMigrationSql();
expect(sql).toMatch(
/CREATE TABLE "xiaobao_risk_summaries"[\s\S]*CONSTRAINT "xiaobao_risk_summaries_pkey" PRIMARY KEY \("version_id"\)/,
);
expect(sql).not.toMatch(/CREATE TABLE "xiaobao_risk_summaries"[\s\S]*PARTITION BY/);
});
});

View File

@@ -0,0 +1,50 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const schemaPath = join(process.cwd(), 'prisma', 'schema.prisma');
function readSchema() {
return readFileSync(schemaPath, 'utf8');
}
function modelBlock(schema: string, modelName: string) {
const match = schema.match(new RegExp(`model ${modelName} \\{[\\s\\S]*?\\n\\}`));
if (!match) throw new Error(`Missing Prisma model: ${modelName}`);
return match[0];
}
describe('V2.2 Prisma partitioned model shape', () => {
it('models partitioned business tables with composite ids that include the partition key', () => {
const schema = readSchema();
expect(modelBlock(schema, 'Requirement')).toMatch(/@@id\(\[id, productId\]\)/);
expect(modelBlock(schema, 'DevTask')).toMatch(/@@id\(\[id, versionId\]\)/);
expect(modelBlock(schema, 'TestCase')).toMatch(/@@id\(\[id, versionId\]\)/);
expect(modelBlock(schema, 'Bug')).toMatch(/@@id\(\[id, versionId\]\)/);
});
it('keeps partition-key-scoped unique business codes in Prisma models', () => {
const schema = readSchema();
expect(modelBlock(schema, 'Requirement')).toMatch(/@@unique\(\[productId, code\]\)/);
expect(modelBlock(schema, 'DevTask')).toMatch(/@@unique\(\[versionId, code\]\)/);
expect(modelBlock(schema, 'TestCase')).toMatch(/@@unique\(\[versionId, code\]\)/);
expect(modelBlock(schema, 'Bug')).toMatch(/@@unique\(\[versionId, code\]\)/);
});
it('models append-only partitioned tables with createdAt in the composite id', () => {
const schema = readSchema();
expect(modelBlock(schema, 'WorkActivity')).toMatch(/@@id\(\[id, createdAt\]\)/);
expect(modelBlock(schema, 'TaskWorklog')).toMatch(/@@id\(\[id, createdAt\]\)/);
expect(modelBlock(schema, 'OvertimeRecord')).toMatch(/@@id\(\[id, createdAt\]\)/);
expect(modelBlock(schema, 'XiaobaoRiskSnapshot')).toMatch(/@@id\(\[id, createdAt\]\)/);
expect(modelBlock(schema, 'AiLog')).toMatch(/@@id\(\[id, createdAt\]\)/);
});
it('keeps Xiaobao current summaries keyed by version id only', () => {
const schema = readSchema();
expect(modelBlock(schema, 'XiaobaoRiskSummary')).toMatch(/versionId\s+String\s+@id/);
});
});

View File

@@ -5,9 +5,10 @@ import { RequirementModule } from './modules/requirement/requirement.module';
import { AiModule } from './modules/ai/ai.module'; import { AiModule } from './modules/ai/ai.module';
import { ConfigModule } from './modules/config/config.module'; import { ConfigModule } from './modules/config/config.module';
import { DataModule } from './modules/data/data.module'; import { DataModule } from './modules/data/data.module';
import { MigrationModule } from './modules/migration/migration.module';
@Module({ @Module({
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, AiModule], imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, AiModule],
controllers: [], controllers: [],
providers: [], providers: [],
}) })

View File

@@ -0,0 +1,415 @@
import { mapAppDataToV22Rows } from './app-data-v22.mapper';
const now = '2026-01-04T10:00:00.000Z';
function buildAppData(): Record<string, any> {
return {
'products-overview': [
{
id: 'product-1',
name: 'FTB',
description: 'Product suite',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
projects: [
{
id: 'project-1',
name: 'CRM',
description: 'Customer project',
createdAt: '2026-01-01T01:00:00.000Z',
},
],
versions: [
{
id: 'version-1',
name: 'CRM V1.0',
description: 'First release',
status: 'in_progress',
releaseDate: '2026-02-01',
expectedReleaseDate: '2026-02-03',
createdAt: '2026-01-01T02:00:00.000Z',
updatedAt: '2026-01-02T02:00:00.000Z',
},
],
},
],
requirements: {
requirements: [
{
id: 'req-1',
code: 'REQ-001',
title: 'Customer import',
description: 'Import customers in bulk',
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
sourceType: 'customer',
sourceTarget: 'ACME',
platforms: ['Web', 'App'],
typeId: 'type-feature',
status: 'adopted',
priority: 'P1',
creator: 'member-pm',
createdAt: '2026-01-02T08:00:00.000Z',
updatedAt: '2026-01-02T09:00:00.000Z',
},
],
},
'task-categories': [
{
id: 'cat-fe',
code: 'frontend_development',
name: 'Frontend development',
group: 'development',
isSystem: true,
},
{
id: 'cat-test',
code: 'test_functional',
name: 'Functional test',
group: 'testing',
isSystem: true,
},
],
'version-plans': [
{
id: 'plan-1',
versionId: 'version-1',
type: 'product',
title: 'Product plan',
owner: 'member-pm',
startTime: '2026-01-02T09:00:00.000Z',
endTime: '2026-01-02T18:00:00.000Z',
status: 'completed',
requirementCoverage: [
{
requirementId: 'req-1',
status: 'completed',
updatedAt: now,
updatedBy: 'member-pm',
},
],
logs: [
{
id: 'plan-log-1',
type: 'requirement_progress',
createdAt: now,
actor: 'member-pm',
title: 'Requirement completed',
},
],
resultUrl: 'https://example.com/prototype',
createdAt: '2026-01-02T08:30:00.000Z',
completedAt: '2026-01-02T17:00:00.000Z',
addedBy: 'member-pm',
},
],
'dev-tasks': [
{
id: 'dev-1',
taskNo: 'DEV-001',
requirementId: 'req-1',
title: 'Build import UI',
description: 'Upload and preview CSV',
categoryId: 'cat-fe',
assigneeId: 'member-dev',
priority: 'P0',
expectedStartAt: '2026-01-03T09:00:00.000Z',
expectedEndAt: '2026-01-03T18:00:00.000Z',
actualStartAt: '2026-01-03T10:00:00.000Z',
actualEndAt: '2026-01-03T17:30:00.000Z',
estimateHours: 6,
aiEstimateHours: 7,
status: 'submitted',
isBlocked: false,
references: [{ type: 'requirement', id: 'req-1', label: 'REQ-001' }],
aiDraft: false,
createdBy: 'member-pm',
createdAt: '2026-01-03T08:30:00.000Z',
updatedAt: '2026-01-03T17:30:00.000Z',
},
],
'test-cases': [
{
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'version-1',
requirementId: 'req-1',
title: 'Import success path',
description: 'Upload a valid CSV',
categoryId: 'cat-test',
priority: 'P2',
assigneeId: 'member-test',
status: 'failed',
roundNo: 1,
plannedTestAt: '2026-01-04T09:00:00.000Z',
plannedEndAt: '2026-01-04T12:00:00.000Z',
startedAt: '2026-01-04T09:30:00.000Z',
completedAt: '2026-01-04T10:30:00.000Z',
estimateHours: 2,
aiEstimateHours: 2.5,
references: [{ type: 'requirement', id: 'req-1', label: 'REQ-001' }],
createdBy: 'member-test',
createdAt: '2026-01-04T08:00:00.000Z',
updatedAt: '2026-01-04T10:30:00.000Z',
},
],
bugs: [
{
id: 'bug-1',
bugNo: 'BUG-001',
versionId: 'version-1',
testCaseId: 'tc-1',
requirementId: 'req-1',
title: 'Preview count is wrong',
description: 'Total rows shows 0',
severity: 'major',
priority: 'P1',
reportedBy: 'member-test',
assigneeId: 'member-dev',
status: 'fixing',
plannedFixAt: '2026-01-04T18:00:00.000Z',
createdAt: '2026-01-04T10:40:00.000Z',
updatedAt: '2026-01-04T11:00:00.000Z',
},
],
'work-activities': [
{
id: 'activity-1',
actorId: 'member-dev',
date: '2026-01-03',
occurredAt: '2026-01-03T10:00:00.000Z',
sourceType: 'dev_task',
sourceId: 'dev-1',
action: 'dev_task_started',
category: 'progress',
title: 'Started import UI',
summary: 'Started development',
metadata: { toStatus: 'in_progress' },
},
],
'task-worklogs': [
{
id: 'worklog-1',
taskId: 'dev-1',
userId: 'member-dev',
date: '2026-01-03',
hours: 2.5,
workContent: 'Built CSV preview',
createdAt: '2026-01-03T19:00:00.000Z',
},
],
overtime: {
records: [
{
id: 'overtime-1',
projectId: 'project-1',
versionId: 'version-1',
person: 'member-dev',
startTime: '2026-01-03T18:00:00.000Z',
endTime: '2026-01-03T20:00:00.000Z',
duration: 2,
reasonId: 'reason-3',
remark: 'Release sprint',
createdAt: '2026-01-03T20:10:00.000Z',
},
],
},
'xiaobao-risk-snapshots': [
{
versionId: 'version-1',
date: '2026-01-04',
riskScore: 72,
riskLevel: 'at_risk',
forecastReleaseDate: '2026-02-05',
openBugCount: 3,
criticalBugCount: 1,
failedTestCount: 2,
blockedCount: 1,
silentRiskCount: 0,
confidence: 80,
createdAt: '2026-01-04T12:00:00.000Z',
},
],
'xiaobao-risk-insights': [
{
versionId: 'version-1',
riskSignature: 'risk-signature-1',
insight: { summary: 'Risk is rising', why: ['Bug count rose'] },
generatedAt: '2026-01-04T12:05:00.000Z',
providerInfo: { providerId: 'anthropic', model: 'claude-test' },
},
],
};
}
describe('mapAppDataToV22Rows', () => {
it('maps AppData entities into partition-key-ready relational rows', () => {
const result = mapAppDataToV22Rows(buildAppData());
expect(result.products).toEqual([
expect.objectContaining({ id: 'product-1', name: 'FTB' }),
]);
expect(result.projects).toEqual([
expect.objectContaining({ id: 'project-1', productId: 'product-1', name: 'CRM' }),
]);
expect(result.versions).toEqual([
expect.objectContaining({
id: 'version-1',
productId: 'product-1',
projectId: 'project-1',
releaseDate: '2026-02-01',
}),
]);
expect(result.requirements).toEqual([
expect.objectContaining({
id: 'req-1',
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
code: 'REQ-001',
priority: 1,
platform: 'Web,App',
creatorId: 'member-pm',
}),
]);
expect(result.devTasks).toEqual([
expect.objectContaining({
id: 'dev-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
requirementId: 'req-1',
requirementProductId: 'product-1',
code: 'DEV-001',
priority: 0,
startDate: '2026-01-03T10:00:00.000Z',
completedAt: '2026-01-03T17:30:00.000Z',
}),
]);
expect(result.testCases).toEqual([
expect.objectContaining({
id: 'tc-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
requirementProductId: 'product-1',
code: 'TC-001',
priority: 2,
}),
]);
expect(result.bugs).toEqual([
expect.objectContaining({
id: 'bug-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
testCaseId: 'tc-1',
testCaseVersionId: 'version-1',
code: 'BUG-001',
priority: 1,
}),
]);
expect(result.skipped).toEqual([]);
});
it('maps append-only activity, worklog, overtime, and Xiaobao rows with createdAt partition keys', () => {
const result = mapAppDataToV22Rows(buildAppData());
expect(result.workActivities).toEqual([
expect.objectContaining({
id: 'activity-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
sourceType: 'dev_task',
sourceId: 'dev-1',
sourceVersionId: 'version-1',
occurredAt: '2026-01-03T10:00:00.000Z',
createdAt: '2026-01-03T10:00:00.000Z',
}),
]);
expect(result.taskWorklogs).toEqual([
expect.objectContaining({
id: 'worklog-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
sourceType: 'dev_task',
sourceId: 'dev-1',
sourceVersionId: 'version-1',
workDate: '2026-01-03',
createdAt: '2026-01-03T19:00:00.000Z',
}),
]);
expect(result.overtimeRecords).toEqual([
expect.objectContaining({
id: 'overtime-1',
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
userId: 'member-dev',
reason: 'reason-3',
hours: 2,
createdAt: '2026-01-03T20:10:00.000Z',
}),
]);
expect(result.xiaobaoRiskSnapshots).toEqual([
expect.objectContaining({
versionId: 'version-1',
snapshotDate: '2026-01-04',
riskLevel: 'at_risk',
riskScore: 72,
riskSignature: 'version-1|2026-01-04|72|at_risk|2026-02-05|3|1|2|1|0|80',
createdAt: '2026-01-04T12:00:00.000Z',
}),
]);
expect(result.xiaobaoRiskInsights).toEqual([
expect.objectContaining({
versionId: 'version-1',
riskSignature: 'risk-signature-1',
status: 'generated',
createdAt: '2026-01-04T12:05:00.000Z',
}),
]);
expect(result.xiaobaoRiskSummaries).toEqual([
expect.objectContaining({
versionId: 'version-1',
riskLevel: 'at_risk',
riskScore: 72,
confidence: 80,
riskSignature: 'version-1|2026-01-04|72|at_risk|2026-02-05|3|1|2|1|0|80',
}),
]);
});
it('skips rows that cannot resolve the partition key instead of inventing unsafe ownership', () => {
const appData = buildAppData();
appData['dev-tasks'].push({
id: 'dev-orphan',
taskNo: 'DEV-999',
requirementId: 'missing-req',
title: 'Cannot resolve version',
categoryId: 'cat-fe',
assigneeId: 'member-dev',
priority: 'P2',
expectedStartAt: '',
expectedEndAt: '',
status: 'todo',
isBlocked: false,
createdBy: 'member-pm',
createdAt: now,
updatedAt: now,
});
const result = mapAppDataToV22Rows(appData);
expect(result.devTasks.map((task: { id: string }) => task.id)).toEqual(['dev-1']);
expect(result.skipped).toContainEqual({
key: 'dev-tasks',
id: 'dev-orphan',
reason: 'missing version partition context',
});
});
});

View File

@@ -0,0 +1,922 @@
type AppDataRecord = Record<string, unknown>;
interface SkipReason {
key: string;
id?: string;
reason: string;
}
interface ProductRow {
id: string;
name: string;
description: string;
createdAt?: string;
updatedAt?: string;
}
interface ProjectRow {
id: string;
productId: string;
name: string;
description: string;
createdAt?: string;
updatedAt?: string;
}
interface VersionRow {
id: string;
productId: string;
projectId?: string;
name: string;
description: string;
releaseDate?: string;
createdAt?: string;
updatedAt?: string;
}
interface RequirementRow {
id: string;
productId: string;
projectId?: string;
versionId?: string;
code: string;
title: string;
description: string;
status: string;
priority: number;
type?: string;
sourceType?: string;
sourceTarget?: string;
platform?: string;
creatorId?: string;
createdAt?: string;
updatedAt?: string;
}
interface TaskCategoryRow {
id: string;
name: string;
code?: string;
group: string;
isSystem: boolean;
createdAt?: string;
updatedAt?: string;
}
interface VersionPlanRow {
id: string;
versionId: string;
productId: string;
projectId?: string;
type: string;
title: string;
status: string;
ownerId?: string;
expectedStartAt?: string;
expectedEndAt?: string;
actualStartAt?: string;
completedAt?: string;
resultUrl?: string;
requirementCoverage: unknown[];
logs: unknown[];
createdAt?: string;
updatedAt?: string;
}
interface DevTaskRow {
id: string;
versionId: string;
productId: string;
projectId: string;
requirementId?: string;
requirementProductId?: string;
categoryId?: string;
code: string;
title: string;
description: string;
status: string;
priority: number;
assigneeId?: string;
creatorId?: string;
isBlocked: boolean;
blockReason?: string;
expectedStartAt?: string;
expectedEndAt?: string;
startDate?: string;
completedAt?: string;
estimateHours?: number;
aiEstimateHours?: number;
references: unknown[];
aiDraft: boolean;
aiDraftAt?: string;
createdAt?: string;
updatedAt?: string;
}
interface TestCaseRow {
id: string;
versionId: string;
productId: string;
projectId: string;
requirementId?: string;
requirementProductId?: string;
categoryId?: string;
code: string;
title: string;
description: string;
status: string;
roundNo: number;
priority: number;
assigneeId?: string;
creatorId?: string;
plannedTestAt?: string;
plannedEndAt?: string;
startedAt?: string;
completedAt?: string;
estimateHours?: number;
aiEstimateHours?: number;
references: unknown[];
aiDraft: boolean;
aiDraftAt?: string;
createdAt?: string;
updatedAt?: string;
}
interface BugRow {
id: string;
versionId: string;
productId: string;
projectId: string;
testCaseId?: string;
testCaseVersionId?: string;
code: string;
title: string;
description: string;
status: string;
severity: string;
priority: number;
assigneeId?: string;
reporterId?: string;
plannedFixAt?: string;
resolvedAt?: string;
closedAt?: string;
resolution?: string;
createdAt?: string;
updatedAt?: string;
}
interface WorkActivityRow {
id: string;
versionId?: string;
productId?: string;
projectId?: string;
actorId?: string;
actorName: string;
sourceType: string;
sourceId: string;
sourceVersionId?: string;
action: string;
title: string;
metadata: unknown;
occurredAt: string;
createdAt: string;
}
interface TaskWorklogRow {
id: string;
versionId?: string;
productId?: string;
projectId?: string;
userId?: string;
sourceType: string;
sourceId: string;
sourceVersionId?: string;
workDate: string;
hours: number;
content: string;
createdAt: string;
updatedAt?: string;
}
interface OvertimeRecordRow {
id: string;
productId?: string;
projectId?: string;
versionId?: string;
userId?: string;
reason: string;
startAt: string;
endAt: string;
hours: number;
createdAt: string;
updatedAt?: string;
}
interface XiaobaoRiskSnapshotRow {
id: string;
versionId: string;
snapshotDate: string;
riskLevel: string;
riskScore: number;
riskSignature: string;
snapshot: unknown;
createdAt: string;
}
interface XiaobaoRiskInsightRow {
id: string;
versionId: string;
riskSignature: string;
status: string;
insight: unknown;
createdAt: string;
updatedAt?: string;
}
interface XiaobaoRiskSummaryRow {
versionId: string;
riskLevel: string;
riskScore: number;
confidence: number;
forecastReleaseDate?: string;
riskSignature: string;
summary: unknown;
dirty: boolean;
recomputedAt?: string;
updatedAt?: string;
}
interface SourceContext {
versionId?: string;
productId?: string;
projectId?: string;
}
export interface V22MappedRows {
products: ProductRow[];
projects: ProjectRow[];
versions: VersionRow[];
requirements: RequirementRow[];
taskCategories: TaskCategoryRow[];
versionPlans: VersionPlanRow[];
devTasks: DevTaskRow[];
testCases: TestCaseRow[];
bugs: BugRow[];
workActivities: WorkActivityRow[];
taskWorklogs: TaskWorklogRow[];
overtimeRecords: OvertimeRecordRow[];
xiaobaoRiskSnapshots: XiaobaoRiskSnapshotRow[];
xiaobaoRiskInsights: XiaobaoRiskInsightRow[];
xiaobaoRiskSummaries: XiaobaoRiskSummaryRow[];
aiLogs: unknown[];
skipped: SkipReason[];
}
export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22MappedRows {
const skipped: SkipReason[] = [];
const products: ProductRow[] = [];
const projects: ProjectRow[] = [];
const versions: VersionRow[] = [];
const requirements: RequirementRow[] = [];
const taskCategories: TaskCategoryRow[] = [];
const versionPlans: VersionPlanRow[] = [];
const devTasks: DevTaskRow[] = [];
const testCases: TestCaseRow[] = [];
const bugs: BugRow[] = [];
const workActivities: WorkActivityRow[] = [];
const taskWorklogs: TaskWorklogRow[] = [];
const overtimeRecords: OvertimeRecordRow[] = [];
const xiaobaoRiskSnapshots: XiaobaoRiskSnapshotRow[] = [];
const xiaobaoRiskInsights: XiaobaoRiskInsightRow[] = [];
const xiaobaoRiskSummaries: XiaobaoRiskSummaryRow[] = [];
const productById = new Map<string, ProductRow>();
const projectById = new Map<string, ProjectRow>();
const versionById = new Map<string, VersionRow>();
const requirementById = new Map<string, RequirementRow>();
const devTaskById = new Map<string, DevTaskRow>();
const testCaseById = new Map<string, TestCaseRow>();
const bugById = new Map<string, BugRow>();
const versionPlanById = new Map<string, VersionPlanRow>();
const productRecords = asArray(appData['products-overview']);
for (const [productIndex, product] of productRecords.entries()) {
const productId = stringField(product, 'id') ?? `product-${productIndex + 1}`;
const productRow: ProductRow = {
id: productId,
name: stringField(product, 'name') ?? productId,
description: stringField(product, 'description') ?? '',
createdAt: stringField(product, 'createdAt'),
updatedAt: stringField(product, 'updatedAt') ?? stringField(product, 'createdAt'),
};
products.push(productRow);
productById.set(productId, productRow);
const productProjects = asArray(product.projects);
for (const [projectIndex, project] of productProjects.entries()) {
const projectId = stringField(project, 'id') ?? `${productId}-project-${projectIndex + 1}`;
const projectRow: ProjectRow = {
id: projectId,
productId,
name: stringField(project, 'name') ?? projectId,
description: stringField(project, 'description') ?? '',
createdAt: stringField(project, 'createdAt') ?? productRow.createdAt,
updatedAt: stringField(project, 'updatedAt') ?? stringField(project, 'createdAt') ?? productRow.updatedAt,
};
projects.push(projectRow);
projectById.set(projectId, projectRow);
}
const productVersions = asArray(product.versions);
for (const [versionIndex, version] of productVersions.entries()) {
const versionId = stringField(version, 'id') ?? `${productId}-version-${versionIndex + 1}`;
const projectId = stringField(version, 'projectId') ?? inferProjectIdForVersion(version, productProjects);
const versionRow: VersionRow = {
id: versionId,
productId,
projectId,
name: stringField(version, 'name') ?? versionId,
description: stringField(version, 'description') ?? '',
releaseDate: stringField(version, 'releaseDate') ?? stringField(version, 'expectedReleaseDate'),
createdAt: stringField(version, 'createdAt') ?? productRow.createdAt,
updatedAt: stringField(version, 'updatedAt') ?? stringField(version, 'createdAt') ?? productRow.updatedAt,
};
versions.push(versionRow);
versionById.set(versionId, versionRow);
}
}
const defaultProductId = products[0]?.id;
const requirementCode = scopedCodeFactory('REQ');
for (const [index, requirement] of readNestedArray(appData.requirements, 'requirements').entries()) {
const id = stringField(requirement, 'id') ?? `requirement-${index + 1}`;
const productId = stringField(requirement, 'productId') ?? defaultProductId;
if (!productId) {
skipped.push({ key: 'requirements', id, reason: 'missing product partition context' });
continue;
}
const versionId = stringField(requirement, 'versionId');
const version = versionId ? versionById.get(versionId) : undefined;
const platforms = unknownArray(requirement.platforms)
.map((platform) => stringFromUnknown(platform))
.filter((platform): platform is string => Boolean(platform));
const row: RequirementRow = {
id,
productId,
projectId: stringField(requirement, 'projectId') ?? version?.projectId,
versionId,
code: requirementCode(productId, stringField(requirement, 'code'), index),
title: stringField(requirement, 'title') ?? id,
description: stringField(requirement, 'description') ?? '',
status: stringField(requirement, 'status') ?? 'pending_review',
priority: priorityRank(requirement.priority),
type: stringField(requirement, 'type') ?? stringField(requirement, 'typeId'),
sourceType: stringField(requirement, 'sourceType'),
sourceTarget: stringField(requirement, 'sourceTarget'),
platform: platforms.join(',') || stringField(requirement, 'platform'),
creatorId: stringField(requirement, 'creatorId') ?? stringField(requirement, 'creator'),
createdAt: stringField(requirement, 'createdAt'),
updatedAt: stringField(requirement, 'updatedAt') ?? stringField(requirement, 'createdAt'),
};
requirements.push(row);
requirementById.set(row.id, row);
}
for (const [index, category] of asArray(appData['task-categories']).entries()) {
const id = stringField(category, 'id') ?? `category-${index + 1}`;
taskCategories.push({
id,
name: stringField(category, 'name') ?? id,
code: stringField(category, 'code'),
group: stringField(category, 'group') ?? 'other',
isSystem: booleanField(category, 'isSystem') ?? false,
createdAt: stringField(category, 'createdAt'),
updatedAt: stringField(category, 'updatedAt') ?? stringField(category, 'createdAt'),
});
}
for (const [index, plan] of asArray(appData['version-plans']).entries()) {
const id = stringField(plan, 'id') ?? `version-plan-${index + 1}`;
const versionId = stringField(plan, 'versionId');
const version = versionId ? versionById.get(versionId) : undefined;
if (!versionId || !version?.productId) {
skipped.push({ key: 'version-plans', id, reason: 'missing version partition context' });
continue;
}
const row: VersionPlanRow = {
id,
versionId,
productId: version.productId,
projectId: version.projectId,
type: stringField(plan, 'type') ?? 'product',
title: stringField(plan, 'title') ?? id,
status: stringField(plan, 'status') ?? 'pending',
ownerId: stringField(plan, 'ownerId') ?? stringField(plan, 'owner'),
expectedStartAt: stringField(plan, 'expectedStartAt') ?? stringField(plan, 'startTime'),
expectedEndAt: stringField(plan, 'expectedEndAt') ?? stringField(plan, 'endTime'),
actualStartAt: stringField(plan, 'actualStartAt'),
completedAt: stringField(plan, 'completedAt'),
resultUrl: stringField(plan, 'resultUrl'),
requirementCoverage: unknownArray(plan.requirementCoverage),
logs: unknownArray(plan.logs),
createdAt: stringField(plan, 'createdAt'),
updatedAt: stringField(plan, 'updatedAt') ?? stringField(plan, 'completedAt') ?? stringField(plan, 'createdAt'),
};
versionPlans.push(row);
versionPlanById.set(row.id, row);
}
const devCode = scopedCodeFactory('DEV');
for (const [index, task] of asArray(appData['dev-tasks']).entries()) {
const id = stringField(task, 'id') ?? `dev-task-${index + 1}`;
const context = resolveVersionContext(task, requirementById, versionById);
if (!context.versionId || !context.productId || !context.projectId) {
skipped.push({ key: 'dev-tasks', id, reason: 'missing version partition context' });
continue;
}
const requirementId = stringField(task, 'requirementId');
const requirement = requirementId ? requirementById.get(requirementId) : undefined;
const row: DevTaskRow = {
id,
versionId: context.versionId,
productId: context.productId,
projectId: context.projectId,
requirementId,
requirementProductId: requirement?.productId,
categoryId: stringField(task, 'categoryId'),
code: devCode(context.versionId, stringField(task, 'code') ?? stringField(task, 'taskNo'), index),
title: stringField(task, 'title') ?? id,
description: stringField(task, 'description') ?? '',
status: stringField(task, 'status') ?? 'todo',
priority: priorityRank(task.priority),
assigneeId: stringField(task, 'assigneeId'),
creatorId: stringField(task, 'creatorId') ?? stringField(task, 'createdBy'),
isBlocked: booleanField(task, 'isBlocked') ?? false,
blockReason: stringField(task, 'blockReason'),
expectedStartAt: stringField(task, 'expectedStartAt'),
expectedEndAt: stringField(task, 'expectedEndAt'),
startDate: stringField(task, 'startDate') ?? stringField(task, 'actualStartAt'),
completedAt: stringField(task, 'completedAt') ?? stringField(task, 'actualEndAt'),
estimateHours: numberField(task, 'estimateHours'),
aiEstimateHours: numberField(task, 'aiEstimateHours'),
references: unknownArray(task.references),
aiDraft: booleanField(task, 'aiDraft') ?? false,
aiDraftAt: stringField(task, 'aiDraftAt'),
createdAt: stringField(task, 'createdAt'),
updatedAt: stringField(task, 'updatedAt') ?? stringField(task, 'createdAt'),
};
devTasks.push(row);
devTaskById.set(row.id, row);
}
const testCode = scopedCodeFactory('TC');
for (const [index, testCase] of asArray(appData['test-cases']).entries()) {
const id = stringField(testCase, 'id') ?? `test-case-${index + 1}`;
const context = resolveVersionContext(testCase, requirementById, versionById);
if (!context.versionId || !context.productId || !context.projectId) {
skipped.push({ key: 'test-cases', id, reason: 'missing version partition context' });
continue;
}
const requirementId = stringField(testCase, 'requirementId');
const requirement = requirementId ? requirementById.get(requirementId) : undefined;
const row: TestCaseRow = {
id,
versionId: context.versionId,
productId: context.productId,
projectId: context.projectId,
requirementId,
requirementProductId: requirement?.productId,
categoryId: stringField(testCase, 'categoryId'),
code: testCode(context.versionId, stringField(testCase, 'code') ?? stringField(testCase, 'caseNo'), index),
title: stringField(testCase, 'title') ?? id,
description: stringField(testCase, 'description') ?? '',
status: stringField(testCase, 'status') ?? 'pending',
roundNo: integerField(testCase, 'roundNo') ?? 1,
priority: priorityRank(testCase.priority),
assigneeId: stringField(testCase, 'assigneeId'),
creatorId: stringField(testCase, 'creatorId') ?? stringField(testCase, 'createdBy'),
plannedTestAt: stringField(testCase, 'plannedTestAt'),
plannedEndAt: stringField(testCase, 'plannedEndAt'),
startedAt: stringField(testCase, 'startedAt'),
completedAt: stringField(testCase, 'completedAt'),
estimateHours: numberField(testCase, 'estimateHours'),
aiEstimateHours: numberField(testCase, 'aiEstimateHours'),
references: unknownArray(testCase.references),
aiDraft: booleanField(testCase, 'aiDraft') ?? false,
aiDraftAt: stringField(testCase, 'aiDraftAt'),
createdAt: stringField(testCase, 'createdAt'),
updatedAt: stringField(testCase, 'updatedAt') ?? stringField(testCase, 'createdAt'),
};
testCases.push(row);
testCaseById.set(row.id, row);
}
const bugCode = scopedCodeFactory('BUG');
for (const [index, bug] of asArray(appData.bugs).entries()) {
const id = stringField(bug, 'id') ?? `bug-${index + 1}`;
const testCaseId = stringField(bug, 'testCaseId');
const linkedTestCase = testCaseId ? testCaseById.get(testCaseId) : undefined;
const context = resolveVersionContext(
{ ...bug, versionId: stringField(bug, 'versionId') ?? linkedTestCase?.versionId },
requirementById,
versionById,
);
if (!context.versionId || !context.productId || !context.projectId) {
skipped.push({ key: 'bugs', id, reason: 'missing version partition context' });
continue;
}
const row: BugRow = {
id,
versionId: context.versionId,
productId: context.productId,
projectId: context.projectId,
testCaseId,
testCaseVersionId: linkedTestCase?.versionId,
code: bugCode(context.versionId, stringField(bug, 'code') ?? stringField(bug, 'bugNo'), index),
title: stringField(bug, 'title') ?? id,
description: stringField(bug, 'description') ?? '',
status: stringField(bug, 'status') ?? 'open',
severity: stringField(bug, 'severity') ?? 'minor',
priority: priorityRank(bug.priority),
assigneeId: stringField(bug, 'assigneeId'),
reporterId: stringField(bug, 'reporterId') ?? stringField(bug, 'reportedBy'),
plannedFixAt: stringField(bug, 'plannedFixAt'),
resolvedAt: stringField(bug, 'resolvedAt'),
closedAt: stringField(bug, 'closedAt'),
resolution: stringField(bug, 'resolution'),
createdAt: stringField(bug, 'createdAt'),
updatedAt: stringField(bug, 'updatedAt') ?? stringField(bug, 'createdAt'),
};
bugs.push(row);
bugById.set(row.id, row);
}
for (const [index, activity] of asArray(appData['work-activities']).entries()) {
const id = stringField(activity, 'id') ?? `work-activity-${index + 1}`;
const occurredAt = stringField(activity, 'occurredAt') ?? stringField(activity, 'createdAt');
if (!occurredAt) {
skipped.push({ key: 'work-activities', id, reason: 'missing createdAt partition context' });
continue;
}
const sourceType = stringField(activity, 'sourceType') ?? 'manual';
const sourceId = stringField(activity, 'sourceId') ?? id;
const sourceContext = resolveSourceContext(sourceType, sourceId, {
devTaskById,
testCaseById,
bugById,
versionPlanById,
});
workActivities.push({
id,
versionId: sourceContext.versionId,
productId: sourceContext.productId,
projectId: sourceContext.projectId,
actorId: stringField(activity, 'actorId'),
actorName: stringField(activity, 'actorName') ?? '',
sourceType,
sourceId,
sourceVersionId: sourceContext.versionId,
action: stringField(activity, 'action') ?? 'progress_note_added',
title: stringField(activity, 'title') ?? stringField(activity, 'summary') ?? id,
metadata: recordField(activity, 'metadata') ?? {},
occurredAt,
createdAt: stringField(activity, 'createdAt') ?? occurredAt,
});
}
for (const [index, worklog] of asArray(appData['task-worklogs']).entries()) {
const id = stringField(worklog, 'id') ?? `task-worklog-${index + 1}`;
const createdAt = stringField(worklog, 'createdAt');
const sourceId = stringField(worklog, 'sourceId') ?? stringField(worklog, 'taskId') ?? id;
const sourceContext = resolveWorklogSource(sourceId, { devTaskById, testCaseById, bugById });
taskWorklogs.push({
id,
versionId: sourceContext.context.versionId,
productId: sourceContext.context.productId,
projectId: sourceContext.context.projectId,
userId: stringField(worklog, 'userId'),
sourceType: stringField(worklog, 'sourceType') ?? sourceContext.sourceType,
sourceId,
sourceVersionId: sourceContext.context.versionId,
workDate: stringField(worklog, 'workDate') ?? stringField(worklog, 'date') ?? sliceDate(createdAt) ?? '',
hours: numberField(worklog, 'hours') ?? 0,
content: stringField(worklog, 'content') ?? stringField(worklog, 'workContent') ?? '',
createdAt: createdAt ?? stringField(worklog, 'date') ?? '',
updatedAt: stringField(worklog, 'updatedAt') ?? createdAt,
});
}
for (const [index, overtime] of readNestedArray(appData.overtime, 'records').entries()) {
const id = stringField(overtime, 'id') ?? `overtime-${index + 1}`;
const versionId = stringField(overtime, 'versionId');
const projectId = stringField(overtime, 'projectId');
const version = versionId ? versionById.get(versionId) : undefined;
const project = projectId ? projectById.get(projectId) : undefined;
const startAt = stringField(overtime, 'startAt') ?? stringField(overtime, 'startTime');
const endAt = stringField(overtime, 'endAt') ?? stringField(overtime, 'endTime');
const createdAt = stringField(overtime, 'createdAt') ?? startAt;
if (!startAt || !endAt || !createdAt) {
skipped.push({ key: 'overtime', id, reason: 'missing createdAt partition context' });
continue;
}
overtimeRecords.push({
id,
productId: stringField(overtime, 'productId') ?? version?.productId ?? project?.productId,
projectId: projectId ?? version?.projectId,
versionId,
userId: stringField(overtime, 'userId') ?? stringField(overtime, 'person'),
reason: stringField(overtime, 'reason') ?? stringField(overtime, 'reasonId') ?? '',
startAt,
endAt,
hours: numberField(overtime, 'hours') ?? numberField(overtime, 'duration') ?? 0,
createdAt,
updatedAt: stringField(overtime, 'updatedAt') ?? createdAt,
});
}
const summaryByVersion = new Map<string, XiaobaoRiskSummaryRow>();
for (const [index, snapshot] of asArray(appData['xiaobao-risk-snapshots']).entries()) {
const versionId = stringField(snapshot, 'versionId');
const snapshotDate = stringField(snapshot, 'snapshotDate') ?? stringField(snapshot, 'date');
const createdAt = stringField(snapshot, 'createdAt') ?? snapshotDate;
if (!versionId || !snapshotDate || !createdAt) {
skipped.push({
key: 'xiaobao-risk-snapshots',
id: stringField(snapshot, 'id') ?? `xiaobao-risk-snapshot-${index + 1}`,
reason: 'missing createdAt partition context',
});
continue;
}
const riskSignature = stringField(snapshot, 'riskSignature') ?? buildRiskSignature(snapshot, versionId, snapshotDate);
const row: XiaobaoRiskSnapshotRow = {
id: stringField(snapshot, 'id') ?? `risk-snapshot-${versionId}-${snapshotDate}-${index + 1}`,
versionId,
snapshotDate,
riskLevel: stringField(snapshot, 'riskLevel') ?? 'on_track',
riskScore: integerField(snapshot, 'riskScore') ?? 0,
riskSignature,
snapshot,
createdAt,
};
xiaobaoRiskSnapshots.push(row);
const summary: XiaobaoRiskSummaryRow = {
versionId,
riskLevel: row.riskLevel,
riskScore: row.riskScore,
confidence: integerField(snapshot, 'confidence') ?? 0,
forecastReleaseDate: stringField(snapshot, 'forecastReleaseDate'),
riskSignature,
summary: snapshot,
dirty: false,
recomputedAt: createdAt,
updatedAt: createdAt,
};
const previous = summaryByVersion.get(versionId);
if (!previous || compareIso(summary.updatedAt, previous.updatedAt) >= 0) {
summaryByVersion.set(versionId, summary);
}
}
xiaobaoRiskSummaries.push(...summaryByVersion.values());
for (const [index, insight] of asArray(appData['xiaobao-risk-insights']).entries()) {
const versionId = stringField(insight, 'versionId');
const riskSignature = stringField(insight, 'riskSignature');
const createdAt = stringField(insight, 'createdAt') ?? stringField(insight, 'generatedAt');
if (!versionId || !riskSignature || !createdAt) {
skipped.push({
key: 'xiaobao-risk-insights',
id: stringField(insight, 'id') ?? `xiaobao-risk-insight-${index + 1}`,
reason: 'missing createdAt partition context',
});
continue;
}
xiaobaoRiskInsights.push({
id: stringField(insight, 'id') ?? `risk-insight-${versionId}-${index + 1}`,
versionId,
riskSignature,
status: stringField(insight, 'status') ?? 'generated',
insight: recordField(insight, 'insight') ?? {},
createdAt,
updatedAt: stringField(insight, 'updatedAt') ?? createdAt,
});
}
return {
products,
projects,
versions,
requirements,
taskCategories,
versionPlans,
devTasks,
testCases,
bugs,
workActivities,
taskWorklogs,
overtimeRecords,
xiaobaoRiskSnapshots,
xiaobaoRiskInsights,
xiaobaoRiskSummaries,
aiLogs: [],
skipped,
};
}
function asRecord(value: unknown): AppDataRecord | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
return value as AppDataRecord;
}
function asArray(value: unknown): AppDataRecord[] {
if (!Array.isArray(value)) return [];
return value.map(asRecord).filter((item): item is AppDataRecord => Boolean(item));
}
function unknownArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function readNestedArray(value: unknown, key: string): AppDataRecord[] {
if (Array.isArray(value)) return asArray(value);
return asArray(asRecord(value)?.[key]);
}
function stringFromUnknown(value: unknown): string | undefined {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || undefined;
}
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return undefined;
}
function stringField(record: AppDataRecord, key: string): string | undefined {
return stringFromUnknown(record[key]);
}
function recordField(record: AppDataRecord, key: string): AppDataRecord | undefined {
return asRecord(record[key]);
}
function numberField(record: AppDataRecord, key: string): number | undefined {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function integerField(record: AppDataRecord, key: string): number | undefined {
const value = numberField(record, key);
return typeof value === 'number' ? Math.floor(value) : undefined;
}
function booleanField(record: AppDataRecord, key: string): boolean | undefined {
const value = record[key];
if (typeof value === 'boolean') return value;
return undefined;
}
function priorityRank(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
if (typeof value === 'string') {
const normalized = value.trim().toUpperCase();
const match = /^P([0-4])$/.exec(normalized);
if (match) return Number(match[1]);
const numeric = Number(normalized);
if (Number.isFinite(numeric)) return Math.max(0, Math.floor(numeric));
}
return 0;
}
function scopedCodeFactory(prefix: string) {
const usedByScope = new Map<string, Set<string>>();
return (scope: string, candidate: string | undefined, index: number): string => {
const used = usedByScope.get(scope) ?? new Set<string>();
usedByScope.set(scope, used);
const base = candidate?.trim() || `${prefix}-${String(index + 1).padStart(3, '0')}`;
if (!used.has(base)) {
used.add(base);
return base;
}
let suffix = 2;
while (used.has(`${base}-${suffix}`)) suffix++;
const next = `${base}-${suffix}`;
used.add(next);
return next;
};
}
function inferProjectIdForVersion(version: AppDataRecord, projects: AppDataRecord[]): string | undefined {
const explicitProjectId = stringField(version, 'projectId');
if (explicitProjectId) return explicitProjectId;
const versionName = stringField(version, 'name')?.toLowerCase();
if (!versionName) return undefined;
const project = projects.find((item) => {
const projectName = stringField(item, 'name')?.toLowerCase();
return Boolean(projectName && versionName.startsWith(projectName));
});
return project ? stringField(project, 'id') : undefined;
}
function resolveVersionContext(
row: AppDataRecord,
requirementById: Map<string, RequirementRow>,
versionById: Map<string, VersionRow>,
): SourceContext {
const requirementId = stringField(row, 'requirementId');
const requirement = requirementId ? requirementById.get(requirementId) : undefined;
const versionId = stringField(row, 'versionId') ?? requirement?.versionId;
const version = versionId ? versionById.get(versionId) : undefined;
return {
versionId,
productId: stringField(row, 'productId') ?? requirement?.productId ?? version?.productId,
projectId: stringField(row, 'projectId') ?? requirement?.projectId ?? version?.projectId,
};
}
function resolveSourceContext(
sourceType: string,
sourceId: string,
maps: {
devTaskById: Map<string, DevTaskRow>;
testCaseById: Map<string, TestCaseRow>;
bugById: Map<string, BugRow>;
versionPlanById: Map<string, VersionPlanRow>;
},
): SourceContext {
if (sourceType === 'dev_task') return contextFromRow(maps.devTaskById.get(sourceId));
if (sourceType === 'test_case') return contextFromRow(maps.testCaseById.get(sourceId));
if (sourceType === 'bug') return contextFromRow(maps.bugById.get(sourceId));
if (sourceType === 'version_plan') return contextFromRow(maps.versionPlanById.get(sourceId));
return {};
}
function resolveWorklogSource(
sourceId: string,
maps: {
devTaskById: Map<string, DevTaskRow>;
testCaseById: Map<string, TestCaseRow>;
bugById: Map<string, BugRow>;
},
): { sourceType: string; context: SourceContext } {
const devTask = maps.devTaskById.get(sourceId);
if (devTask) return { sourceType: 'dev_task', context: contextFromRow(devTask) };
const testCase = maps.testCaseById.get(sourceId);
if (testCase) return { sourceType: 'test_case', context: contextFromRow(testCase) };
const bug = maps.bugById.get(sourceId);
if (bug) return { sourceType: 'bug', context: contextFromRow(bug) };
return { sourceType: 'legacy_task', context: {} };
}
function contextFromRow(row: SourceContext | undefined): SourceContext {
if (!row) return {};
return {
versionId: row.versionId,
productId: row.productId,
projectId: row.projectId,
};
}
function sliceDate(value?: string): string | undefined {
if (!value) return undefined;
return /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : undefined;
}
function compareIso(a?: string, b?: string): number {
const aTime = a ? new Date(a).getTime() : 0;
const bTime = b ? new Date(b).getTime() : 0;
const safeA = Number.isFinite(aTime) ? aTime : 0;
const safeB = Number.isFinite(bTime) ? bTime : 0;
return safeA - safeB;
}
function buildRiskSignature(snapshot: AppDataRecord, versionId: string, date: string): string {
return [
versionId,
date,
clampScore(integerField(snapshot, 'riskScore') ?? 0),
stringField(snapshot, 'riskLevel') ?? 'on_track',
normalizeForecastDate(stringField(snapshot, 'forecastReleaseDate')),
integerField(snapshot, 'openBugCount') ?? 0,
integerField(snapshot, 'criticalBugCount') ?? 0,
integerField(snapshot, 'failedTestCount') ?? 0,
integerField(snapshot, 'blockedCount') ?? 0,
integerField(snapshot, 'silentRiskCount') ?? 0,
clampScore(integerField(snapshot, 'confidence') ?? 0),
].join('|');
}
function clampScore(score: number): number {
return Math.max(0, Math.min(100, Math.round(score)));
}
function normalizeForecastDate(value?: string): string {
if (!value) return '';
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return value.slice(0, 10);
const time = new Date(value).getTime();
if (!Number.isFinite(time)) return value;
return new Date(time).toISOString().slice(0, 10);
}

View File

@@ -0,0 +1,73 @@
import { APP_DATA_KEYS } from '../data/data-keys';
import { AppDataV22MigrationService } from './app-data-v22.migration.service';
describe('AppDataV22MigrationService', () => {
it('loads allowed AppData keys and returns a migration preview without writing domain tables', async () => {
const findMany = jest.fn().mockResolvedValue([
{
key: 'products-overview',
value: [
{
id: 'product-1',
name: 'FTB',
projects: [{ id: 'project-1', name: 'CRM', description: '', createdAt: '2026-01-01T00:00:00.000Z' }],
versions: [{ id: 'version-1', name: 'CRM V1.0', createdAt: '2026-01-01T00:00:00.000Z' }],
},
],
},
{
key: 'requirements',
value: {
requirements: [
{
id: 'req-1',
code: 'REQ-001',
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
title: 'Customer import',
createdAt: '2026-01-01T00:00:00.000Z',
},
],
},
},
{
key: 'dev-tasks',
value: [
{
id: 'dev-1',
taskNo: 'DEV-001',
requirementId: 'req-1',
title: 'Build import UI',
status: 'todo',
isBlocked: false,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
},
],
},
]);
const createMany = jest.fn();
const prisma = { appData: { findMany }, requirement: { createMany } };
const service = new AppDataV22MigrationService(prisma as any);
const preview = await service.preview();
expect(findMany).toHaveBeenCalledWith({
where: { key: { in: APP_DATA_KEYS } },
select: { key: true, value: true },
});
expect(createMany).not.toHaveBeenCalled();
expect(preview.readyToImport).toBe(true);
expect(preview.counts).toEqual(
expect.objectContaining({
products: 1,
projects: 1,
versions: 1,
requirements: 1,
devTasks: 1,
}),
);
expect(preview.skipped).toEqual([]);
});
});

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { APP_DATA_KEYS } from '../data/data-keys';
import { mapAppDataToV22Rows, type V22MappedRows } from './app-data-v22.mapper';
const COUNT_KEYS = [
'products',
'projects',
'versions',
'requirements',
'taskCategories',
'versionPlans',
'devTasks',
'testCases',
'bugs',
'workActivities',
'taskWorklogs',
'overtimeRecords',
'xiaobaoRiskSnapshots',
'xiaobaoRiskInsights',
'xiaobaoRiskSummaries',
'aiLogs',
] as const;
type CountKey = (typeof COUNT_KEYS)[number];
export interface AppDataV22MigrationPreview {
readyToImport: boolean;
counts: Record<CountKey, number>;
skipped: V22MappedRows['skipped'];
}
@Injectable()
export class AppDataV22MigrationService {
constructor(private readonly prisma: PrismaService) {}
async loadSnapshot(): Promise<Record<string, unknown>> {
const rows = await this.prisma.appData.findMany({
where: { key: { in: [...APP_DATA_KEYS] } },
select: { key: true, value: true },
});
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
mapSnapshot(snapshot: Record<string, unknown>): V22MappedRows {
return mapAppDataToV22Rows(snapshot);
}
async preview(): Promise<AppDataV22MigrationPreview> {
const mapped = this.mapSnapshot(await this.loadSnapshot());
return {
readyToImport: mapped.skipped.length === 0,
counts: countRows(mapped),
skipped: mapped.skipped,
};
}
}
function countRows(mapped: V22MappedRows): Record<CountKey, number> {
return Object.fromEntries(COUNT_KEYS.map((key) => [key, mapped[key].length])) as Record<CountKey, number>;
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AppDataV22MigrationService } from './app-data-v22.migration.service';
@Module({
providers: [AppDataV22MigrationService],
exports: [AppDataV22MigrationService],
})
export class MigrationModule {}

View File

@@ -1,6 +1,10 @@
import { IsString, IsNotEmpty, IsOptional, IsInt, Min, Max } from 'class-validator'; import { IsString, IsNotEmpty, IsOptional, IsInt, Min, Max } from 'class-validator';
export class CreateRequirementDto { export class CreateRequirementDto {
@IsString()
@IsOptional()
code?: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
title!: string; title!: string;

View File

@@ -0,0 +1,72 @@
import { RequirementService } from './requirement.service';
describe('RequirementService with V2.2 composite requirement key', () => {
const makeService = () => {
const prisma = {
requirement: {
create: jest.fn(),
delete: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
return {
prisma,
service: new RequirementService(prisma as any),
};
};
it('creates requirements with a partition-key-scoped business code', async () => {
const { prisma, service } = makeService();
prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' });
await service.create('product-1', {
code: 'REQ-001',
title: 'Payment',
creatorId: 'user-1',
});
expect(prisma.requirement.create).toHaveBeenCalledWith({
data: expect.objectContaining({
productId: 'product-1',
code: 'REQ-001',
title: 'Payment',
}),
});
});
it('updates requirements by id plus product id', async () => {
const { prisma, service } = makeService();
prisma.requirement.findFirst.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: 'draft',
});
prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
await service.update('product-1', 'req-1', { title: 'Payment v2' });
expect(prisma.requirement.update).toHaveBeenCalledWith({
where: { id_productId: { id: 'req-1', productId: 'product-1' } },
data: { title: 'Payment v2' },
});
});
it('deletes requirements by id plus product id', async () => {
const { prisma, service } = makeService();
prisma.requirement.findFirst.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: 'draft',
});
prisma.requirement.delete.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
await service.remove('product-1', 'req-1');
expect(prisma.requirement.delete).toHaveBeenCalledWith({
where: { id_productId: { id: 'req-1', productId: 'product-1' } },
});
});
});

View File

@@ -12,6 +12,13 @@ const VALID_TRANSITIONS: Record<string, string[]> = {
[RequirementStatus.DELIVERED]: [], [RequirementStatus.DELIVERED]: [],
}; };
function createFallbackRequirementCode() {
return `REQ-${Date.now().toString(36).toUpperCase()}-${Math.random()
.toString(36)
.slice(2, 6)
.toUpperCase()}`;
}
@Injectable() @Injectable()
export class RequirementService { export class RequirementService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
@@ -20,6 +27,7 @@ export class RequirementService {
return this.prisma.requirement.create({ return this.prisma.requirement.create({
data: { data: {
productId, productId,
code: dto.code?.trim() || createFallbackRequirementCode(),
title: dto.title, title: dto.title,
description: dto.description || '', description: dto.description || '',
priority: dto.priority ?? 0, priority: dto.priority ?? 0,
@@ -51,8 +59,9 @@ export class RequirementService {
async update(productId: string, id: string, dto: UpdateRequirementDto) { async update(productId: string, id: string, dto: UpdateRequirementDto) {
await this.findOne(productId, id); await this.findOne(productId, id);
return this.prisma.requirement.update({ return this.prisma.requirement.update({
where: { id }, where: { id_productId: { id, productId } },
data: { data: {
...(dto.code !== undefined && { code: dto.code }),
...(dto.title !== undefined && { title: dto.title }), ...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description }), ...(dto.description !== undefined && { description: dto.description }),
...(dto.priority !== undefined && { priority: dto.priority }), ...(dto.priority !== undefined && { priority: dto.priority }),
@@ -69,13 +78,13 @@ export class RequirementService {
); );
} }
return this.prisma.requirement.update({ return this.prisma.requirement.update({
where: { id }, where: { id_productId: { id, productId } },
data: { status: newStatus }, data: { status: newStatus },
}); });
} }
async remove(productId: string, id: string) { async remove(productId: string, id: string) {
await this.findOne(productId, id); await this.findOne(productId, id);
return this.prisma.requirement.delete({ where: { id } }); return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } });
} }
} }

View File

@@ -220,3 +220,26 @@ Managers with `xiaobao.warning:manage` can see all unfinished versions. Non-mana
AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened. The first version uses page-triggered analysis rather than a background scheduled Agent. AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened. The first version uses page-triggered analysis rather than a background scheduled Agent.
Per-user warning read state is saved to `xiaobao-warning-views`. The read marker stores `userId + versionId + risk signature`, so the sidebar can turn the Xiaobao badge blue when any visible risk has a completed unread update, then return to the red risk-count badge after the user opens every updated warning. AI interpretation that is still generating only shows the "updating" notice and must not produce the blue update badge yet. Per-user warning read state is saved to `xiaobao-warning-views`. The read marker stores `userId + versionId + risk signature`, so the sidebar can turn the Xiaobao badge blue when any visible risk has a completed unread update, then return to the red risk-count badge after the user opens every updated warning. AI interpretation that is still generating only shows the "updating" notice and must not produce the blue update badge yet.
## V2.2 Partitioned Domain Data Layer (2026-07-03)
V2.2 starts the move from AppData JSON documents to relation tables for high-volume domains. The first database foundation is partitioned from the start so future growth does not require a disruptive rewrite of primary keys, unique constraints, and foreign-key references.
High-growth business tables use fixed hash partitions:
- `requirements`: hash partitioned by `product_id`; primary key is `(id, product_id)`.
- `dev_tasks`, `test_cases`, and `bugs`: hash partitioned by `version_id`; primary key is `(id, version_id)`.
Append-only evidence and history tables use range partitions by `created_at`:
- `work_activities`
- `task_worklogs`
- `overtime_records`
- `xiaobao_risk_snapshots`
- `xiaobao_risk_insights`
- `ai_logs`
Partitioned tables must include the partition key in every primary key and business unique constraint. Cross-table references to partitioned tables use composite foreign keys when the referencing row naturally carries the partition key, for example `(requirement_id, requirement_product_id)` and `(test_case_id, test_case_version_id)`. Polymorphic activity references remain logical references.
Xiaobao uses two storage shapes: `xiaobao_risk_summaries` keeps one current row per version for fast reads, while `xiaobao_risk_snapshots` stores append-only history for trend analysis.
The AppData migration path is staged through a pure mapper plus preview service. `AppDataV22MigrationService.preview()` reads the allowed `app_data` keys, maps legacy JSON into partition-key-ready rows, and reports row counts plus skipped records before any insert path is enabled.

View File

@@ -516,3 +516,16 @@
- 人工新建任务表单继续使用任务类型字典作为可选项,但这个字典会随着 AI 草案采纳自动扩展。 - 人工新建任务表单继续使用任务类型字典作为可选项,但这个字典会随着 AI 草案采纳自动扩展。
**理由**:人工表单的任务类型是录入辅助,不应该成为 AI 拆解边界。但任务类型字典也不能被单个原型的业务对象污染。`taskTypeName` 只承载可复用分类,具体功能点放在 `title``description`;采纳时有门槛地补字典,让后续筛选、统计和人工创建复用真正稳定的类型,同时避免 AI 直接写数据库 `categoryId` **理由**:人工表单的任务类型是录入辅助,不应该成为 AI 拆解边界。但任务类型字典也不能被单个原型的业务对象污染。`taskTypeName` 只承载可复用分类,具体功能点放在 `title``description`;采纳时有门槛地补字典,让后续筛选、统计和人工创建复用真正稳定的类型,同时避免 AI 直接写数据库 `categoryId`
## 42. V2.2 高增长业务表从一开始采用分区表
**问题**需求池、开发任务、测试用例、BUG、工作活动和小宝快照会按年持续增长。若先用普通表后续再切分区表不只是搬旧数据还会牵动主键、唯一约束、外键、迁移窗口和回滚方案。
**决策**
- `requirements``product_id` 做固定 HASH 分区,主键为 `(id, product_id)`
- `dev_tasks` / `test_cases` / `bugs``version_id` 做固定 HASH 分区,主键为 `(id, version_id)`
- `work_activities` / `task_worklogs` / `overtime_records` / `xiaobao_risk_snapshots` / `xiaobao_risk_insights` / `ai_logs``created_at` 做 RANGE 分区并保留 default partition。
- 所有分区表主键和业务唯一约束必须包含分区键,例如 `(version_id, code)`
- 引用分区表时优先使用复合外键,例如 `(requirement_id, requirement_product_id)``(test_case_id, test_case_version_id)`;多态活动记录使用逻辑外键。
- `xiaobao_risk_summaries` 不分区,保持 `version_id` 单行汇总;历史趋势进入分区快照表。
**理由**:把分区键纳入主键和唯一约束是 PostgreSQL 分区表的结构性要求。提前做这件事,可以避免客户数据变大后再重塑主键和外键。固定 HASH 分区避免每个项目/版本单独建分区的维护负担RANGE 分区只用于天然追加、按时间维护的历史数据。

View File

@@ -6,6 +6,13 @@
### 已完成(按时间倒序) ### 已完成(按时间倒序)
**2026-07-03**
- V2.2 partitioned domain schema foundation added: `requirements` uses HASH partitioning by `product_id`; `dev_tasks`, `test_cases`, and `bugs` use HASH partitioning by `version_id`.
- Partitioned table primary keys and business unique constraints now include partition keys, for example `(id, version_id)` and `(version_id, code)`.
- Xiaobao precompute storage foundation added: `xiaobao_risk_summaries` stores the current version risk, and `xiaobao_risk_snapshots` stores historical snapshots.
- Prisma schema now includes the V2.2 relational model skeleton, and the legacy `RequirementService` now uses the `(id, product_id)` composite key.
- AppData V2.2 migration mapper and preview service added, so legacy JSON can be rehearsed into relation-table rows with counts and skipped-record diagnostics before inserts are enabled.
**2026-07-02** **2026-07-02**
- `app_data` 读写增加乐观锁版本:`GET` 返回 `version`,前端保存携带最近版本,后端用 `key + updatedAt` 原子更新 - `app_data` 读写增加乐观锁版本:`GET` 返回 `version`,前端保存携带最近版本,后端用 `key + updatedAt` 原子更新
- stale version / create race 返回 `409 APP_DATA_CONFLICT`,阻止多人同时编辑时的静默覆盖 - stale version / create race 返回 `409 APP_DATA_CONFLICT`,阻止多人同时编辑时的静默覆盖