feat(db): A5 审计表分区、append-only 与种子
audit_logs 按月 RANGE 分区、复合主键与 6 组索引;ensure_partitions 幂等建分区;UPDATE/DELETE/TRUNCATE 触发器与 lingxi_app 最小权限;audit_export_jobs;种子新增 3 项配置与 6 条演示审计。
This commit is contained in:
1 parent
408dc5e81b
commit
6215f204ce
6 files changed
+441
-4
No files matched your search
@@ -8,6 +8,8 @@ NODE_ENV=development
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# ---------- 数据库(PostgreSQL 18) ----------
|
||||
# 本地开发沿用 postgres 账号;生产 DATABASE_URL 应使用 A5 迁移创建的 NOLOGIN 角色 lingxi_app
|
||||
# (审计表仅 SELECT / INSERT,append-only 由 DB 触发器兜底;其他业务表的授权见运维 Runbook)。
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/lingxi?schema=public
|
||||
|
||||
# ---------- Redis(7.4) ----------
|
||||
|
||||
@@ -66,6 +66,9 @@ temp/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# ========== 本地运行时数据(A5 审计导出文件,短时效,不入库) ==========
|
||||
apps/api/.data/
|
||||
|
||||
# ========== 项目自定义 ==========
|
||||
# 参考项目:仅本地参照,不纳入版本控制、不推送到远程仓库
|
||||
参考项目/
|
||||
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
# 种子数据状态(A2)
|
||||
# 种子数据状态(A5)
|
||||
|
||||
A0 只 seed「已建表」的 `system_configs`;A1 补齐组织、账号与角色种子;A2 补齐权限点目录、角色权限映射与六类数据范围种子。全部种子按业务唯一键 upsert,**幂等**:重复执行不重复插入,也不覆盖已存在的密码 / 人工调整值。
|
||||
A0 只 seed「已建表」的 `system_configs`;A1 补齐组织、账号与角色种子;A2 补齐权限点目录、角色权限映射与六类数据范围种子;A3 补客户 / 行业;A4 补数据字典;A5 补审计演示数据(append-only,存在即跳过)。全部种子按业务唯一键 upsert / 存在即跳过,**幂等**:重复执行不重复插入,也不覆盖已存在的密码 / 人工调整值。
|
||||
|
||||
> 权限点编码唯一事实源是 `packages/shared/src/permissions.ts`(30 个 / 10 模块 / 6 个特殊权限 / 3 对 SoD),`seed.ts` 直接引用 shared 源码目录,不在本文件复制编码字符串。
|
||||
|
||||
@@ -8,7 +8,7 @@ A0 只 seed「已建表」的 `system_configs`;A1 补齐组织、账号与角
|
||||
|
||||
| 数据 | 来源 | 幂等键 | 说明 |
|
||||
| ------------------------------ | ---------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 平台系统配置 26 项 | `system_configs` | `config_key` | 定义唯一事实源为 `packages/shared/src/config/system-configs.ts`:基础信息 2、命名规则 5、导出与归档 4、通知开关 4、阈值与上限 5、业务阈值 6;A0 的 7 项继续保留,A4 补齐其余配置;重复执行不覆盖人工调整值 / 版本 |
|
||||
| 平台系统配置 29 项 | `system_configs` | `config_key` | 定义唯一事实源为 `packages/shared/src/config/system-configs.ts`:基础信息 2、命名规则 5、导出与归档 4、通知开关 4、阈值与上限 5、业务阈值 6、审计与日志 3(A5:保留期 / 导出上限 / 日志平台地址);A0 的 7 项继续保留;重复执行不覆盖人工调整值 / 版本 |
|
||||
| 部门 / 小组 9 个(两级) | `departments` | `code` | 内容生产中心(素材一组 / 素材二组 / 设计组)、交付中心(客户成功组)、平台技术部(AI 工程组 / 平台运维组);一级部门负责人取 `leader_account`,用户种子之后回填 `leader_user_id` |
|
||||
| 权限点目录 30 个 | `permissions` | `code`(只增不删) | 10 个模块(script / material / asset / delivery / customer / customer_group / identity / industry / platform / audit);启动时由 `PermissionCatalogService` 幂等 upsert,seed 同步一次 |
|
||||
| 角色 10 个 + 角色权限点 | `roles` / `role_permissions` | 角色 `code`;权限映射先清后建 | 与 `design/a2-ui-v1/brief.md` §4 对齐:超级管理员(隐式全集,映射 24 普通 + 6 特殊)、平台管理员 12+1(`audit:read`)、素材运营 9、设计师 4、文案策划 2、审核员 4、AI 工程师 3+1(`ai:configure`)、客户负责人 5、行业配置 4、审核主管 5(停用);非超管角色合计 50 条映射,特殊授予共 8 项;新建角色仍默认全空、特殊权限单独授予 |
|
||||
@@ -16,6 +16,7 @@ A0 只 seed「已建表」的 `system_configs`;A1 补齐组织、账号与角
|
||||
| 用户 11 个(超管 1 + 示例 10) | `users` / `user_roles` | `account`;用户角色先清后建 | 超管 `admin`,示例用户与 A1 原型口径一致(陈默、林清、周岸、赵一诺、高岑、苏禾、沈知远、江岚、唐嘉、何岸);唐嘉 / 何岸为停用态,用于「停用即时失效」联调 |
|
||||
| 用户初始密码 | `users.password_hash` | — | 统一 `lingxi2026`(Argon2id),`must_change_password=true`:首登强制改密 |
|
||||
| A4 数据字典 13 类 / 96 条 | `dict_types` / `dict_items` | 类型 `code`;条目 `(type, code, industry)` | 13 类:平台与规格、光影与风格、时长档、钩子类型、景别、脚本方向、情绪与风格、素材形态、警示语类型、禁用词库、失败原因、淘汰理由、人群标签;4 分组;行业作用域使用 A3 的「电商 / 服饰鞋包 / 金融 / 信贷」等行业 ID;内置类型不可删除,条目编码不可改 |
|
||||
| A5 审计演示 6 条 | `audit_logs` | 固定 ULID(存在即跳过) | 登录、客户编辑、角色权限变更、系统配置保存、审计查看、审计导出各 1 条;actor=admin,时间取当前时间附近(保证落在已建分区);**append-only 表不能 upsert / 删除**,故存在即跳过;集成测试使用独立时间窗与动作筛选,不受影响 |
|
||||
|
||||
## 登录账号(本地联调)
|
||||
|
||||
@@ -54,10 +55,11 @@ pnpm db:seed # 只跑种子;幂等,不覆盖已有密码
|
||||
- A3 起新增客户与行业种子:两级行业「电商(服饰鞋包 / 美妆个护)」「金融(信贷)」、客户「花间集 / 素研」、客户组「电商核心组」、4 条客户级字段(1 条待审批);按业务唯一键幂等写入,不覆盖人工调整。
|
||||
- 字典(A4)、行业包(B0)仍留待后续批次建表,不在本种子内。
|
||||
- `data_scopes` 只存角色声明的范围与授权对象;A3 起由真实 `CustomerDirectory` 在请求期解析(客户负责人 / 客户组 / 部门 / 创建人),种子不写解析结果。
|
||||
- 审计 `audit_logs` 按月分区,迁移与 `audit_logs_ensure_partitions(13)` 保证当月 ~ +13 月分区存在;种子演示数据只用当前时间,避免写入不存在分区的历史月份。
|
||||
- 原有的 `Q-A0-01`(种子验收与建表顺序冲突)已随 A1 建表解除。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 客户转交已由 A3 client 模块落地(`/customers/{id}/transfer` 与 `/users/{id}/transfer-customers`),不再返回占位错误;客户组范围可指向种子客户组。
|
||||
- 审计表属 A5,`@Audit` 当前写结构化日志(含前后值与影响用户数);A5 落库后在此扩展日志 + 审计表双写。
|
||||
- A5 起 `@Audit` 已落库:Pino 结构化日志与 `audit_logs` 双写(成功 / 失败都写,写入失败只记 Pino error);审计表 append-only,DB 触发器拒绝 UPDATE / DELETE / TRUNCATE。
|
||||
- `outbox_events` 已写入 `role.permissions.changed` 占位事件(角色新建 / 更新 / 停用 / 删除),通知投递随 A5 / 任务中心接入。
|
||||
@@ -0,0 +1,178 @@
|
||||
-- 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)。';
|
||||
@@ -442,3 +442,79 @@ model DictItem {
|
||||
@@index([typeId, parentCode])
|
||||
@@map("dict_items")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A5 审计与日志(platform 模块):append-only + 月分区 + 异步导出
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 审计日志(A5-BE-01 / 工程规范 08):append-only,按月 RANGE 分区。
|
||||
///
|
||||
/// - 复合主键 `(id, occurred_at)`:分区键必须包含在主键 / 唯一约束中;
|
||||
/// - 运行时按 id 查询请用 `findFirst`(单传 id 无法定位唯一行);
|
||||
/// - `action_code` / `category` 的英文 code 存表,中文名与分类映射唯一来源是
|
||||
/// `@lingxi/shared` 的 `AUDIT_ACTION_CATALOG`(避免字典漂移);
|
||||
/// - 分区、索引、append-only 触发器与 `lingxi_app` 授权由迁移 SQL 手写(Prisma 不支持分区 DDL);
|
||||
/// - 分区由 `audit_logs_ensure_partitions()` 保证,应用启动与每日定时调用。
|
||||
model AuditLog {
|
||||
id String @db.Char(26)
|
||||
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(3)
|
||||
actorId String? @map("actor_id") @db.Char(26)
|
||||
actorName String? @map("actor_name") @db.VarChar(64)
|
||||
actorAccount String? @map("actor_account") @db.VarChar(64)
|
||||
actionCode String @map("action_code") @db.VarChar(64)
|
||||
category String @db.VarChar(32)
|
||||
targetType String? @map("target_type") @db.VarChar(64)
|
||||
targetId String? @map("target_id") @db.VarChar(128)
|
||||
targetName String? @map("target_name") @db.VarChar(160)
|
||||
customerId String? @map("customer_id") @db.Char(26)
|
||||
customerName String? @map("customer_name") @db.VarChar(128)
|
||||
result String @db.VarChar(16)
|
||||
httpMethod String? @map("http_method") @db.VarChar(8)
|
||||
httpPath String? @map("http_path") @db.VarChar(255)
|
||||
traceId String? @map("trace_id") @db.VarChar(64)
|
||||
ip String? @db.VarChar(64)
|
||||
userAgent String? @map("user_agent") @db.VarChar(255)
|
||||
beforeJson Json? @map("before_json") @db.JsonB
|
||||
afterJson Json? @map("after_json") @db.JsonB
|
||||
changesJson Json? @map("changes_json") @db.JsonB
|
||||
reason String? @db.VarChar(255)
|
||||
errorCode String? @map("error_code") @db.VarChar(32)
|
||||
errorMessage String? @map("error_message") @db.VarChar(500)
|
||||
durationMs Int? @map("duration_ms")
|
||||
|
||||
@@id([id, occurredAt])
|
||||
@@index([occurredAt(sort: Desc), id(sort: Desc)])
|
||||
@@index([actorId, occurredAt(sort: Desc)])
|
||||
@@index([customerId, occurredAt(sort: Desc)])
|
||||
@@index([actionCode, occurredAt(sort: Desc)])
|
||||
@@index([targetType, targetId])
|
||||
@@index([result, occurredAt(sort: Desc)])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
/// 审计异步导出任务(A5-BE-03):普通表(可更新进度),文件落本地临时目录,
|
||||
/// 通过短时效 HMAC 签名链接下载;A6 任务协议就绪后替换 `setImmediate` 执行方式。
|
||||
model AuditExportJob {
|
||||
id String @id @db.Char(26)
|
||||
status String @default("queued") @db.VarChar(16)
|
||||
format String @default("csv") @db.VarChar(16)
|
||||
filtersJson Json @default("{}") @map("filters_json") @db.JsonB
|
||||
requestedBy String @map("requested_by") @db.Char(26)
|
||||
requestedAt DateTime @default(now()) @map("requested_at") @db.Timestamptz(3)
|
||||
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
|
||||
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
|
||||
totalRows Int? @map("total_rows")
|
||||
rowCount Int? @map("row_count")
|
||||
processedRows Int? @map("processed_rows")
|
||||
truncated Boolean @default(false)
|
||||
filePath String? @map("file_path") @db.VarChar(500)
|
||||
fileSize Int? @map("file_size")
|
||||
errorMessage String? @map("error_message") @db.VarChar(500)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
downloadCount Int @default(0) @map("download_count")
|
||||
lastDownloadedAt DateTime? @map("last_downloaded_at") @db.Timestamptz(3)
|
||||
|
||||
@@index([requestedBy, requestedAt(sort: Desc)])
|
||||
@@index([status])
|
||||
@@map("audit_export_jobs")
|
||||
}
|
||||
+176
@@ -898,6 +898,181 @@ async function seedDicts(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A5 审计演示数据(幂等):固定 id + 当前时间附近,写入 6 条覆盖登录 / 客户 / 权限 / 配置 / 审计查看 / 导出。
|
||||
* 注意:audit_logs 是 append-only(DB 层禁 UPDATE / DELETE),因此只能“存在即跳过”,不能 upsert 覆盖。
|
||||
* 集成测试使用独立前缀与筛选条件,不受这些演示数据影响。
|
||||
*/
|
||||
const AUDIT_DEMO_IDS = [
|
||||
'01HZZZZZZZZZZZZZZZZZZZZZ01',
|
||||
'01HZZZZZZZZZZZZZZZZZZZZZ02',
|
||||
'01HZZZZZZZZZZZZZZZZZZZZZ03',
|
||||
'01HZZZZZZZZZZZZZZZZZZZZZ04',
|
||||
'01HZZZZZZZZZZZZZZZZZZZZZ05',
|
||||
'01HZZZZZZZZZZZZZZZZZZZZZ06',
|
||||
] as const;
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function, complexity -- 6 条演示数据集中定义便于核对
|
||||
async function seedAuditDemos(): Promise<void> {
|
||||
const admin = await prisma.user.findUnique({
|
||||
where: { account: 'admin' },
|
||||
select: { id: true, name: true, account: true },
|
||||
});
|
||||
if (admin === null) {
|
||||
console.log('[seed] 跳过 A5 审计演示数据(admin 账号不存在)');
|
||||
return;
|
||||
}
|
||||
const customer = await prisma.customer.findFirst({
|
||||
where: { deletedAt: null },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const role = await prisma.role.findFirst({
|
||||
where: { code: 'platform_admin' },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const demos = [
|
||||
{
|
||||
id: AUDIT_DEMO_IDS[0],
|
||||
occurredAt: new Date(now - 5 * 60_000),
|
||||
actionCode: 'identity.auth.login',
|
||||
category: 'auth',
|
||||
targetType: 'user',
|
||||
targetId: admin.id,
|
||||
targetName: admin.name,
|
||||
result: 'success',
|
||||
httpMethod: 'POST',
|
||||
httpPath: '/api/v1/auth/login',
|
||||
traceId: 'seed-audit-login',
|
||||
beforeJson: null,
|
||||
afterJson: { account: admin.account },
|
||||
changesJson: null,
|
||||
},
|
||||
{
|
||||
id: AUDIT_DEMO_IDS[1],
|
||||
occurredAt: new Date(now - 4 * 60_000),
|
||||
actionCode: 'client.customer.update',
|
||||
category: 'client',
|
||||
targetType: 'customer',
|
||||
targetId: customer?.id ?? null,
|
||||
targetName: customer?.name ?? '示例客户',
|
||||
customerId: customer?.id ?? null,
|
||||
customerName: customer?.name ?? '示例客户',
|
||||
result: 'success',
|
||||
httpMethod: 'PATCH',
|
||||
httpPath: customer === null ? '/api/v1/customers' : `/api/v1/customers/${customer.id}`,
|
||||
traceId: 'seed-audit-customer',
|
||||
beforeJson: { remark: '旧备注' },
|
||||
afterJson: { remark: 'A5 审计演示:客户备注已更新' },
|
||||
changesJson: null,
|
||||
},
|
||||
{
|
||||
id: AUDIT_DEMO_IDS[2],
|
||||
occurredAt: new Date(now - 3 * 60_000),
|
||||
actionCode: 'identity.role.update',
|
||||
category: 'permission',
|
||||
targetType: 'role',
|
||||
targetId: role?.id ?? null,
|
||||
targetName: role?.name ?? '示例角色',
|
||||
result: 'success',
|
||||
httpMethod: 'PATCH',
|
||||
httpPath: role === null ? '/api/v1/roles' : `/api/v1/roles/${role.id}`,
|
||||
traceId: 'seed-audit-role',
|
||||
beforeJson: { permissions: ['user:view'] },
|
||||
afterJson: { permissions: ['user:view', 'role:view'] },
|
||||
changesJson: { permissions: { added: ['role:view'], removed: [] } },
|
||||
},
|
||||
{
|
||||
id: AUDIT_DEMO_IDS[3],
|
||||
occurredAt: new Date(now - 2 * 60_000),
|
||||
actionCode: 'platform.system_config.update',
|
||||
category: 'config',
|
||||
targetType: 'system_config',
|
||||
targetId: 'platform.page.default_limit',
|
||||
targetName: '列表默认条数',
|
||||
result: 'success',
|
||||
httpMethod: 'PUT',
|
||||
httpPath: '/api/v1/system/configs',
|
||||
traceId: 'seed-audit-config',
|
||||
beforeJson: { 'platform.page.default_limit': 20 },
|
||||
afterJson: { 'platform.page.default_limit': 20 },
|
||||
changesJson: [{ key: 'platform.page.default_limit', oldValue: 20, newValue: 20 }],
|
||||
},
|
||||
{
|
||||
id: AUDIT_DEMO_IDS[4],
|
||||
occurredAt: new Date(now - 60_000),
|
||||
actionCode: 'audit.log.view',
|
||||
category: 'sensitive',
|
||||
targetType: 'audit_log',
|
||||
targetId: null,
|
||||
targetName: null,
|
||||
result: 'success',
|
||||
httpMethod: 'GET',
|
||||
httpPath: '/api/v1/audit-logs',
|
||||
traceId: 'seed-audit-view',
|
||||
beforeJson: null,
|
||||
afterJson: null,
|
||||
changesJson: null,
|
||||
},
|
||||
{
|
||||
id: AUDIT_DEMO_IDS[5],
|
||||
occurredAt: new Date(now - 30_000),
|
||||
actionCode: 'audit.export',
|
||||
category: 'export',
|
||||
targetType: 'audit_export',
|
||||
targetId: null,
|
||||
targetName: '审计导出 CSV',
|
||||
result: 'success',
|
||||
httpMethod: 'POST',
|
||||
httpPath: '/api/v1/audit-logs/export',
|
||||
traceId: 'seed-audit-export',
|
||||
beforeJson: null,
|
||||
afterJson: { format: 'csv', filters: { result: 'failure' }, rowCount: 120 },
|
||||
changesJson: null,
|
||||
},
|
||||
];
|
||||
|
||||
let created = 0;
|
||||
for (const demo of demos) {
|
||||
const existing = await prisma.auditLog.findFirst({
|
||||
where: { id: demo.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing !== null) continue;
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
id: demo.id,
|
||||
occurredAt: demo.occurredAt,
|
||||
actorId: admin.id,
|
||||
actorName: admin.name,
|
||||
actorAccount: admin.account,
|
||||
actionCode: demo.actionCode,
|
||||
category: demo.category,
|
||||
targetType: demo.targetType,
|
||||
targetId: demo.targetId,
|
||||
targetName: demo.targetName,
|
||||
customerId: 'customerId' in demo ? demo.customerId : null,
|
||||
customerName: 'customerName' in demo ? demo.customerName : null,
|
||||
result: demo.result,
|
||||
httpMethod: demo.httpMethod,
|
||||
httpPath: demo.httpPath,
|
||||
traceId: demo.traceId,
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'seed',
|
||||
beforeJson: demo.beforeJson ?? undefined,
|
||||
afterJson: demo.afterJson ?? undefined,
|
||||
changesJson: demo.changesJson ?? undefined,
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
console.log(
|
||||
`[seed] A5 审计演示数据就绪:新增 ${String(created)} 条 / 共 ${String(AUDIT_DEMO_IDS.length)} 条(幂等)`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await seedConfigs();
|
||||
await seedPermissions();
|
||||
@@ -907,6 +1082,7 @@ async function main(): Promise<void> {
|
||||
await seedDepartmentLeaders();
|
||||
await seedClient();
|
||||
await seedDicts();
|
||||
await seedAuditDemos();
|
||||
}
|
||||
|
||||
void main()
|
||||
|
||||
Reference in new issue
Block a user