Files
lingxi/prisma/migrations/20261104054622_a5_audit_logs/migration.sql
T
Stev_Wang 6215f204ce feat(db): A5 审计表分区、append-only 与种子
audit_logs 按月 RANGE 分区、复合主键与 6 组索引;ensure_partitions 幂等建分区;UPDATE/DELETE/TRUNCATE 触发器与 lingxi_app 最小权限;audit_export_jobs;种子新增 3 项配置与 6 条演示审计。
2026-11-04 16:26:20 +08:00

179 lines
7.3 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- A5-BE-01 · 审计表(月分区 + append-only)与异步导出任务表
--
-- Prisma 不支持分区 / 触发器 / 角色授权 DDL,本迁移在生成骨架后手写以下部分:
-- 1. `audit_logs` 按月 RANGE 分区(复合主键 id + occurred_at),无 default 分区;
-- 2. `audit_logs_ensure_partitions()` 幂等建分区,应用启动 + 每日定时调用;
-- 3. BEFORE UPDATE / DELETE 行触发器 + BEFORE TRUNCATE 语句触发器拒绝篡改;
-- 4. 创建 NOLOGIN 角色 `lingxi_app`,仅授予审计表 SELECT / INSERT(生产 DATABASE_URL 应使用该角色)。
--
-- 回滚方式:`audit_logs` / `audit_export_jobs` 为 A5 新增表,回滚可删除表与函数;
-- 角色 `lingxi_app` 与授权保留(重跑迁移幂等)。禁止对 append-only 表执行 UPDATE / DELETE。
-- CreateTable(分区键 occurred_at 必须包含在唯一约束中)
CREATE TABLE "audit_logs" (
"id" CHAR(26) NOT NULL,
"occurred_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"actor_id" CHAR(26),
"actor_name" VARCHAR(64),
"actor_account" VARCHAR(64),
"action_code" VARCHAR(64) NOT NULL,
"category" VARCHAR(32) NOT NULL,
"target_type" VARCHAR(64),
"target_id" VARCHAR(128),
"target_name" VARCHAR(160),
"customer_id" CHAR(26),
"customer_name" VARCHAR(128),
"result" VARCHAR(16) NOT NULL,
"http_method" VARCHAR(8),
"http_path" VARCHAR(255),
"trace_id" VARCHAR(64),
"ip" VARCHAR(64),
"user_agent" VARCHAR(255),
"before_json" JSONB,
"after_json" JSONB,
"changes_json" JSONB,
"reason" VARCHAR(255),
"error_code" VARCHAR(32),
"error_message" VARCHAR(500),
"duration_ms" INTEGER,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id","occurred_at")
) PARTITION BY RANGE ("occurred_at");
-- CreateTable(普通表:导出任务需要更新状态 / 进度 / 下载计数)
CREATE TABLE "audit_export_jobs" (
"id" CHAR(26) NOT NULL,
"status" VARCHAR(16) NOT NULL DEFAULT 'queued',
"format" VARCHAR(16) NOT NULL DEFAULT 'csv',
"filters_json" JSONB NOT NULL DEFAULT '{}',
"requested_by" CHAR(26) NOT NULL,
"requested_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"started_at" TIMESTAMPTZ(3),
"finished_at" TIMESTAMPTZ(3),
"total_rows" INTEGER,
"row_count" INTEGER,
"processed_rows" INTEGER,
"truncated" BOOLEAN NOT NULL DEFAULT false,
"file_path" VARCHAR(500),
"file_size" INTEGER,
"error_message" VARCHAR(500),
"expires_at" TIMESTAMPTZ(3),
"download_count" INTEGER NOT NULL DEFAULT 0,
"last_downloaded_at" TIMESTAMPTZ(3),
CONSTRAINT "audit_export_jobs_pkey" PRIMARY KEY ("id")
);
-- ---------------------------------------------------------------------------
-- 分区自动创建:从当月创建到 +months_ahead 月,幂等;应用启动 / 每日定时调用。
-- 不使用 default 分区(避免后续 ATTACH 冲突;写入范围由 ensure 保证)。
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION audit_logs_ensure_partitions(months_ahead int DEFAULT 13)
RETURNS int
LANGUAGE plpgsql
AS $$
DECLARE
i int;
start_ts timestamptz;
end_ts timestamptz;
part_name text;
created int := 0;
BEGIN
IF months_ahead IS NULL OR months_ahead < 0 OR months_ahead > 120 THEN
RAISE EXCEPTION 'audit_logs_ensure_partitions: months_ahead 必须在 0..120 之间(收到 %)', months_ahead
USING ERRCODE = 'invalid_parameter_value';
END IF;
FOR i IN 0..months_ahead LOOP
start_ts := date_trunc('month', now()) + make_interval(months => i);
end_ts := start_ts + interval '1 month';
part_name := 'audit_logs_' || to_char(start_ts, 'YYYYMM');
IF to_regclass(format('%I.%I', current_schema(), part_name)) IS NULL THEN
EXECUTE format(
'CREATE TABLE %I PARTITION OF audit_logs FOR VALUES FROM (%L) TO (%L)',
part_name, start_ts, end_ts
);
created := created + 1;
END IF;
END LOOP;
RETURN created;
END;
$$;
COMMENT ON FUNCTION audit_logs_ensure_partitions(int) IS
'幂等创建 audit_logs 月分区(当月 ~ +months_ahead 月);返回本次新建分区数。应用启动与每日定时调用。';
-- 迁移时立即创建 14 个月分区,保证 seed / 首次启动即可写入
SELECT audit_logs_ensure_partitions(13);
-- CreateIndex(父表索引会递归创建到已有分区,未来分区自动继承)
CREATE INDEX "audit_logs_occurred_at_id_idx" ON "audit_logs"("occurred_at" DESC, "id" DESC);
-- CreateIndex
CREATE INDEX "audit_logs_actor_id_occurred_at_idx" ON "audit_logs"("actor_id", "occurred_at" DESC);
-- CreateIndex
CREATE INDEX "audit_logs_customer_id_occurred_at_idx" ON "audit_logs"("customer_id", "occurred_at" DESC);
-- CreateIndex
CREATE INDEX "audit_logs_action_code_occurred_at_idx" ON "audit_logs"("action_code", "occurred_at" DESC);
-- CreateIndex
CREATE INDEX "audit_logs_target_type_target_id_idx" ON "audit_logs"("target_type", "target_id");
-- CreateIndex
CREATE INDEX "audit_logs_result_occurred_at_idx" ON "audit_logs"("result", "occurred_at" DESC);
-- CreateIndex
CREATE INDEX "audit_export_jobs_requested_by_requested_at_idx" ON "audit_export_jobs"("requested_by", "requested_at" DESC);
-- CreateIndex
CREATE INDEX "audit_export_jobs_status_idx" ON "audit_export_jobs"("status");
-- ---------------------------------------------------------------------------
-- append-only:任何 UPDATE / DELETE / TRUNCATE 都在 DB 层拒绝(含表 owner)。
-- 触发器是本地 postgres 账号的兜底;生产应用账号使用 lingxi_app(仅 SELECT / INSERT)。
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION audit_logs_block_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'audit_logs 为 append-only 审计表,禁止 % 操作', TG_OP
USING ERRCODE = 'insufficient_privilege',
HINT = '审计日志仅允许 INSERT;归档 / 清理不得原地 UPDATE 或 DELETE(A5-BE-04 归档仅做只读搬迁)。';
END;
$$;
CREATE TRIGGER audit_logs_no_update_delete
BEFORE UPDATE OR DELETE ON audit_logs
FOR EACH ROW EXECUTE FUNCTION audit_logs_block_mutation();
CREATE TRIGGER audit_logs_no_truncate
BEFORE TRUNCATE ON audit_logs
FOR EACH STATEMENT EXECUTE FUNCTION audit_logs_block_mutation();
-- 显式收回 PUBLIC 的写权限(新表默认无授权,此处表达 intent,便于审计授权差异)
REVOKE UPDATE, DELETE, TRUNCATE ON "audit_logs" FROM PUBLIC;
-- 应用角色:生产 DATABASE_URL 应使用 lingxi_app(NOLOGIN 角色由部署侧授予登录或使用 SET ROLE);
-- 本地继续使用 postgres,由触发器兜底 append-only 约束。
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'lingxi_app') THEN
CREATE ROLE lingxi_app NOLOGIN;
END IF;
END;
$$;
GRANT SELECT, INSERT ON "audit_logs" TO lingxi_app;
-- 导出任务表是应用可更新的普通表(队列状态 / 进度 / 下载计数)
GRANT SELECT, INSERT, UPDATE ON "audit_export_jobs" TO lingxi_app;
COMMENT ON TABLE audit_logs IS
'审计日志(append-only,按月 RANGE 分区;仅 INSERT,禁止 UPDATE / DELETE / TRUNCATE)。分区由 audit_logs_ensure_partitions 保证。';
COMMENT ON TABLE audit_export_jobs IS
'审计异步导出任务;文件短时效签名下载,任务本身可更新进度(A5-BE-03)。';