feat(db): 0006 构建/验收/审计域 + 追加式审计

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-20 20:24:18 +08:00
parent c09a9b31f6
commit e905cba729
5 changed files with 2995 additions and 1 deletions
@@ -0,0 +1,107 @@
CREATE TYPE "public"."audit_actor_type" AS ENUM('system', 'cli_user');--> statement-breakpoint
CREATE TYPE "public"."build_artifact_kind" AS ENUM('dist', 'manifest', 'report', 'log');--> statement-breakpoint
CREATE TYPE "public"."build_job_status" AS ENUM('queued', 'snapshotting', 'building', 'validating', 'succeeded', 'failed', 'cancelled', 'stale');--> statement-breakpoint
CREATE TABLE "audit_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"site_id" uuid,
"actor_type" "audit_actor_type" NOT NULL,
"actor_id" text,
"actor_label" text NOT NULL,
"command_id" text,
"action" text NOT NULL,
"object_type" text NOT NULL,
"object_id" text NOT NULL,
"before_value" jsonb,
"after_value" jsonb,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "build_artifacts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"build_job_id" uuid NOT NULL,
"kind" "build_artifact_kind" NOT NULL,
"storage_path" text NOT NULL,
"sha256" text NOT NULL,
"byte_size" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "build_input_snapshots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"build_job_id" uuid NOT NULL,
"site_id" uuid NOT NULL,
"content_snapshot_hash" text NOT NULL,
"route_snapshot_hash" text NOT NULL,
"asset_manifest_hash" text NOT NULL,
"site_config_revision_id" uuid,
"theme_key" text NOT NULL,
"theme_version" text NOT NULL,
"theme_source_hash" text NOT NULL,
"dependency_lock_hash" text NOT NULL,
"source_commit" text NOT NULL,
"node_version" text NOT NULL,
"pnpm_version" text NOT NULL,
"astro_version" text NOT NULL,
"locale" text NOT NULL,
"timezone" text NOT NULL,
"build_timestamp" timestamp with time zone NOT NULL,
"manifest_path" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "build_input_snapshots_build_job_id_unique" UNIQUE("build_job_id")
);
--> statement-breakpoint
CREATE TABLE "build_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"site_id" uuid NOT NULL,
"status" "build_job_status" NOT NULL,
"idempotency_key" text NOT NULL,
"attempt" integer DEFAULT 0 NOT NULL,
"worker_id" text,
"started_at" timestamp with time zone,
"heartbeat_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"error_code" text,
"error_summary" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "build_jobs_idempotency_key_unique" UNIQUE("idempotency_key")
);
--> statement-breakpoint
CREATE TABLE "validation_issues" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"validation_run_id" uuid NOT NULL,
"severity" text NOT NULL,
"rule" text NOT NULL,
"url" text,
"evidence" jsonb NOT NULL,
"resolution_status" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "validation_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"site_id" uuid NOT NULL,
"build_job_id" uuid,
"rule_version" text NOT NULL,
"status" text NOT NULL,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "build_artifacts" ADD CONSTRAINT "build_artifacts_build_job_id_build_jobs_id_fk" FOREIGN KEY ("build_job_id") REFERENCES "public"."build_jobs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "build_input_snapshots" ADD CONSTRAINT "build_input_snapshots_build_job_id_build_jobs_id_fk" FOREIGN KEY ("build_job_id") REFERENCES "public"."build_jobs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "build_input_snapshots" ADD CONSTRAINT "build_input_snapshots_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "build_input_snapshots" ADD CONSTRAINT "build_input_snapshots_site_config_revision_id_site_config_revisions_id_fk" FOREIGN KEY ("site_config_revision_id") REFERENCES "public"."site_config_revisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "build_jobs" ADD CONSTRAINT "build_jobs_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "validation_issues" ADD CONSTRAINT "validation_issues_validation_run_id_validation_runs_id_fk" FOREIGN KEY ("validation_run_id") REFERENCES "public"."validation_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "validation_runs" ADD CONSTRAINT "validation_runs_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "validation_runs" ADD CONSTRAINT "validation_runs_build_job_id_build_jobs_id_fk" FOREIGN KEY ("build_job_id") REFERENCES "public"."build_jobs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "build_artifacts_uq" ON "build_artifacts" USING btree ("build_job_id","kind","storage_path");--> statement-breakpoint
-- ↓ 手写追加(drizzle-kit 不生成触发器):输入快照不可变 + 审计日志追加式(禁改禁删)。
-- sf_forbid_update 为 0001 建立的共享函数,此处复用(其报错文案 "immutable table %: UPDATE forbidden"
-- 同样覆盖 DELETE——触发器挂在 BEFORE UPDATE OR DELETE 上,两条路径走同一函数)。
CREATE TRIGGER build_input_snapshots_immutable BEFORE UPDATE OR DELETE ON build_input_snapshots
FOR EACH ROW EXECUTE FUNCTION sf_forbid_update();--> statement-breakpoint
CREATE TRIGGER audit_logs_immutable BEFORE UPDATE OR DELETE ON audit_logs
FOR EACH ROW EXECUTE FUNCTION sf_forbid_update();
File diff suppressed because it is too large Load Diff
+7
View File
@@ -43,6 +43,13 @@
"when": 1792498347954,
"tag": "0005_fine_dark_phoenix",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1792498960250,
"tag": "0006_confused_lord_hawal",
"breakpoints": true
}
]
}
+104 -1
View File
@@ -1,4 +1,4 @@
// 聚合导出:Task 6 起按域追加 pgTable 定义(站点域 = 迁移文件 0000_pale_sumo;来源域 = 0001_*;内容域 = 0002_*;分类域 = 0003_*;媒体域 = 0004_*;路由域 = 0005_*)
// 聚合导出:Task 6 起按域追加 pgTable 定义(站点域 = 迁移文件 0000_pale_sumo;来源域 = 0001_*;内容域 = 0002_*;分类域 = 0003_*;媒体域 = 0004_*;路由域 = 0005_*;构建/验收/审计域 = 0006_*)
import { pgTable, pgEnum, uuid, text, integer, bigint, jsonb, timestamp, uniqueIndex, index, boolean, primaryKey, type AnyPgColumn } from 'drizzle-orm/pg-core'
export const sites = pgTable('sites', {
@@ -254,3 +254,106 @@ export const routeRegistry = pgTable('route_registry', {
// 会产出语义错误(默认 NULLS DISTINCT,纯路径路由两行 NULL 不冲突)的迁移。该索引由
// 0005 迁移文件末尾手写 SQL 追加,集成测试 db.routes.test.ts 守住其存在与语义。
})
// ---- 构建/验收/审计域(spec §5.7/§5.8;迁移文件 0006_*,末尾手写追加两不可变触发器)----
export const buildJobStatus = pgEnum('build_job_status', [
'queued', 'snapshotting', 'building', 'validating', 'succeeded', 'failed', 'cancelled', 'stale',
])
export const buildArtifactKind = pgEnum('build_artifact_kind', ['dist', 'manifest', 'report', 'log'])
export const auditActorType = pgEnum('audit_actor_type', ['system', 'cli_user'])
export const buildJobs = pgTable('build_jobs', {
id: uuid('id').primaryKey().defaultRandom(),
siteId: uuid('site_id').notNull().references(() => sites.id),
status: buildJobStatus('status').notNull(),
// CLI 重试/断点续传靠同 key 幂等落库(同 import_jobs.idempotency_key 先例)
idempotencyKey: text('idempotency_key').notNull().unique(),
attempt: integer('attempt').notNull().default(0),
workerId: text('worker_id'),
startedAt: timestamp('started_at', { withTimezone: true }),
heartbeatAt: timestamp('heartbeat_at', { withTimezone: true }),
finishedAt: timestamp('finished_at', { withTimezone: true }),
errorCode: text('error_code'),
errorSummary: text('error_summary'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
// 构建输入快照:可复现性的单一事实来源(内容/路由/资产三 hash + 主题/工具链全版本钉死)。
// 创建后不可变(0006 迁移手写触发器复用 sf_forbid_update);重试读同一行,永不改写。
// build_job_id UNIQUE = 一 job 恰好一份输入快照(一对一)。
export const buildInputSnapshots = pgTable('build_input_snapshots', {
id: uuid('id').primaryKey().defaultRandom(),
buildJobId: uuid('build_job_id').notNull().references(() => buildJobs.id).unique(),
siteId: uuid('site_id').notNull().references(() => sites.id),
contentSnapshotHash: text('content_snapshot_hash').notNull(),
routeSnapshotHash: text('route_snapshot_hash').notNull(),
assetManifestHash: text('asset_manifest_hash').notNull(),
// 可空:极早期 job 可能无配置修订引用(FK 不强制非空)
siteConfigRevisionId: uuid('site_config_revision_id').references(() => siteConfigRevisions.id),
themeKey: text('theme_key').notNull(),
themeVersion: text('theme_version').notNull(),
themeSourceHash: text('theme_source_hash').notNull(),
dependencyLockHash: text('dependency_lock_hash').notNull(),
sourceCommit: text('source_commit').notNull(),
nodeVersion: text('node_version').notNull(),
pnpmVersion: text('pnpm_version').notNull(),
astroVersion: text('astro_version').notNull(),
locale: text('locale').notNull(),
timezone: text('timezone').notNull(),
buildTimestamp: timestamp('build_timestamp', { withTimezone: true }).notNull(),
manifestPath: text('manifest_path').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const buildArtifacts = pgTable('build_artifacts', {
id: uuid('id').primaryKey().defaultRandom(),
buildJobId: uuid('build_job_id').notNull().references(() => buildJobs.id),
kind: buildArtifactKind('kind').notNull(),
storagePath: text('storage_path').notNull(),
sha256: text('sha256').notNull(),
byteSize: bigint('byte_size', { mode: 'number' }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
// 同一 job 内同 kind 同路径只登记一次(重试上传同一路径幂等);跨 job / 换 kind / 换路径 = 新行
}, (t) => [uniqueIndex('build_artifacts_uq').on(t.buildJobId, t.kind, t.storagePath)])
// 验收 Gate 运行记录;build_job_id 可空(可对既有产物重跑 Gate,不必是新构建)
export const validationRuns = pgTable('validation_runs', {
id: uuid('id').primaryKey().defaultRandom(),
siteId: uuid('site_id').notNull().references(() => sites.id),
buildJobId: uuid('build_job_id').references(() => buildJobs.id),
ruleVersion: text('rule_version').notNull(),
status: text('status').notNull(),
startedAt: timestamp('started_at', { withTimezone: true }),
finishedAt: timestamp('finished_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
// severity 用 text 而非 enum:级别集合随 Gate 规则演进(P0-P3 为当前切片约定),不值得一次 ALTER
export const validationIssues = pgTable('validation_issues', {
id: uuid('id').primaryKey().defaultRandom(),
validationRunId: uuid('validation_run_id').notNull().references(() => validationRuns.id),
severity: text('severity').notNull(),
rule: text('rule').notNull(),
url: text('url'),
evidence: jsonb('evidence').notNull(),
resolutionStatus: text('resolution_status').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
// 审计日志追加式(spec §5.8):只 INSERT,UPDATE/DELETE 由 0006 手写触发器拒绝。
// site_id 可空——组织级操作(无站点上下文)记 NULL;CLI 必须带明确 Actor,不用模糊 system 顶替。
export const auditLogs = pgTable('audit_logs', {
id: uuid('id').primaryKey().defaultRandom(),
siteId: uuid('site_id').references(() => sites.id),
actorType: auditActorType('actor_type').notNull(),
actorId: text('actor_id'),
actorLabel: text('actor_label').notNull(),
commandId: text('command_id'),
action: text('action').notNull(),
objectType: text('object_type').notNull(),
objectId: text('object_id').notNull(),
beforeValue: jsonb('before_value'),
afterValue: jsonb('after_value'),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
})