diff --git a/.env.example b/.env.example index de6d2af..8a059dc 100644 --- a/.env.example +++ b/.env.example @@ -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) ---------- diff --git a/.gitignore b/.gitignore index 544c285..31721cd 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,9 @@ temp/ playwright-report/ test-results/ +# ========== 本地运行时数据(A5 审计导出文件,短时效,不入库) ========== +apps/api/.data/ + # ========== 项目自定义 ========== # 参考项目:仅本地参照,不纳入版本控制、不推送到远程仓库 参考项目/ diff --git a/prisma/SEED.md b/prisma/SEED.md index 94184bf..ec15ef8 100644 --- a/prisma/SEED.md +++ b/prisma/SEED.md @@ -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 / 任务中心接入。 diff --git a/prisma/migrations/20261104054622_a5_audit_logs/migration.sql b/prisma/migrations/20261104054622_a5_audit_logs/migration.sql new file mode 100644 index 0000000..b15ef8c --- /dev/null +++ b/prisma/migrations/20261104054622_a5_audit_logs/migration.sql @@ -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)。'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b691a38..88324b3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 5e034f2..b6f7510 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -898,6 +898,181 @@ async function seedDicts(): Promise { ); } +/** + * 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 { + 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 { await seedConfigs(); await seedPermissions(); @@ -907,6 +1082,7 @@ async function main(): Promise { await seedDepartmentLeaders(); await seedClient(); await seedDicts(); + await seedAuditDemos(); } void main()