feat(identity): 完成 A1 身份认证与用户管理全栈落地
ci / 质量门禁 (push) Canceled after 0s

背景:
- 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、
  权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。

改动:
- packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role)
  与 user:view 权限点;写接口 strict 校验。
- apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/
  user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh
  旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。
- apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403;
  usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。
- e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。
- openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。

影响面:
- 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。
- 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。
- 平台导航「用户与组织」由 /platform/users 调整为 /users。

回滚方式:
- 代码整体回滚本提交;迁移为 expand,回滚代码不需删表;
- 本地如需彻底回退按实施报告 §6 处理。

验证证据:
- pnpm format:check / lint / typecheck 通过;
- pnpm test:shared 95 / api 60 / web 55;
- pnpm test:int:7 个(真实 PG/Redis);
- A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6;
- 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
This commit is contained in:
Stev_Wang committed 2026-11-03 00:03:33 +08:00
1 parent f0d218bd99
commit 4d9eb980af
120 files changed
+13112 -314

No files matched your search

+43 -12
View File
@@ -1,17 +1,48 @@
# 种子数据状态(A0)
# 种子数据状态(A1)
A0-INF-02 的计划要求种子包含「超管、示例角色、核心字典、电商行业包占位」。但这四类数据依赖的用户 / 角色 / 字典 / 行业包表分别在 A1(identity)、A3(industry)、A4(catalog)与 B0 批次才建表,A0 阶段提前建表会越过批次边界、造成后续迁移冲突。
A0 只 seed「已建表」的 `system_configs`;A1 建表完成后补齐组织、账号与角色种子。全部种子按业务唯一键 upsert,**幂等**:重复执行不重复插入,也不覆盖已存在的密码 / 人工调整值。
A0 的处理:
## 已 seed(`prisma/seed.ts`)
- 已 seed(`prisma/seed.ts`):`system_configs` 中平台级配置项,按 `config_key` 幂等 upsert,重复执行不重复插入、不覆盖人工调整值。
- 待后续批次接入(保持 TODO,不在 A0 建表):
| 数据 | 来源 | 幂等键 | 说明 |
| ------------------------------ | ---------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 平台系统配置 7 项 | `system_configs` | `config_key` | 平台名、导出链接有效期、上传上限、分页大小、AI 预算比例等,A0 起延续 |
| 部门 / 小组 9 个(两级) | `departments` | `code` | 内容生产中心(素材一组 / 素材二组 / 设计组)、交付中心(客户成功组)、平台技术部(AI 工程组 / 平台运维组);一级部门负责人取 `leader_account`,用户种子之后回填 `leader_user_id` |
| 角色 8 个 + 角色权限点 | `roles` / `role_permissions` | 角色 `code`;权限映射先清后建 | 超级管理员(`is_system=true`,全部权限点)、平台管理员、素材运营、设计师、文案策划、审核员、AI 工程师、客户负责人;权限点取值来自 `@lingxi/shared` `PERMISSION_CODES`(本文件按字符串固化) |
| 用户 11 个(超管 1 + 示例 10) | `users` / `user_roles` | `account`;用户角色先清后建 | 超管 `admin`,示例用户与 A1 原型口径一致(陈默、林清、周岸、赵一诺、高岑、苏禾、沈知远、江岚、唐嘉、何岸);唐嘉 / 何岸为停用态,用于「停用即时失效」联调 |
| 用户初始密码 | `users.password_hash` | — | 统一 `lingxi2026`(Argon2id),`must_change_password=true`:首登强制改密 |
| 种子 | 依赖批次 | 说明 |
| -------------------------------- | ----------- | ------------------------------------------------------------------------ |
| 超级管理员账号 | A1 identity | 需 `users` / `user_roles` 表 |
| 示例角色与权限点 | A2 identity | 需 `roles` / `role_permissions` 表,权限点常量已在 `@lingxi/shared` 定义 |
| 核心字典(素材类型、脚本状态等) | A4 catalog | 需 `dict_types` / `dict_items` 表 |
| 电商行业包占位 | A3 industry | 需 `industry_packs` 表与行业包 schema |
## 登录账号(本地联调)
> 【待确认 Q-A0-01】开发计划 A0-INF-02 的种子验收与批次建表顺序冲突,A0 按「只种已建表数据」执行,待产品/架构确认是否调整该 WBS 描述。
| 账号 | 姓名 | 角色 | 部门 / 小组 | 状态 |
| -------------- | ---------- | -------------------- | ----------------------- | ---- |
| `admin` | 灵犀管理员 | 超级管理员 | 平台技术部(直属) | 在职 |
| `chen.mo` | 陈默 | 素材运营、客户负责人 | 内容生产中心 / 素材一组 | 在职 |
| `lin.qing` | 林清 | 设计师 | 内容生产中心 / 设计组 | 在职 |
| `zhou.an` | 周岸 | 文案策划 | 内容生产中心 / 素材二组 | 在职 |
| `zhao.yinuo` | 赵一诺 | 审核员 | 内容生产中心 / 素材二组 | 在职 |
| `gao.cen` | 高岑 | 客户负责人 | 交付中心 / 客户成功组 | 在职 |
| `su.he` | 苏禾 | AI 工程师 | 平台技术部 / AI 工程组 | 在职 |
| `shen.zhiyuan` | 沈知远 | 超级管理员 | 平台技术部(直属) | 在职 |
| `jiang.lan` | 江岚 | 平台管理员 | 平台技术部 / 平台运维组 | 在职 |
| `tang.jia` | 唐嘉 | 素材运营 | 内容生产中心 / 素材一组 | 停用 |
| `he.an` | 何岸 | 客户负责人 | 交付中心 / 客户成功组 | 停用 |
> 登录账号按共享契约 `accountSchema` 不含 `@`(邮箱本地部分 / 工号风格);邮箱由种子自动生成 `账号@lingxi.cn`。
## 使用
```bash
pnpm db:migrate # 应用 a1_identity 迁移(expand)
pnpm db:seed # 幂等写入上述种子(可重复执行)
# 本地重置:pnpm db:reset(会重新执行全部迁移与 seed)
```
## 与 A0 遗留 TODO 的关系
A0 记录的「超管 / 角色 / 示例用户待 A1」已在本次迁移与种子中补齐;字典(A4)、行业包(A3)仍按批次留待后续建表,不在 A1 种子内。原 `Q-A0-01`(种子验收与建表顺序冲突)随 A1 建表落地而解除。
## 已知限制
- 客户数据(客户、客户组)属 A3,客户转交接口当前返回占位错误 `42201`(`客户转交将在客户模块(A3)提供`),种子不含客户。
- 审计表属 A5,`@Audit` 当前写结构化日志;A5 落库后在此扩展日志 + 审计表双写。
@@ -0,0 +1,154 @@
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'DISABLED');
-- CreateEnum
CREATE TYPE "RoleStatus" AS ENUM ('ACTIVE', 'DISABLED');
-- CreateTable
CREATE TABLE "departments" (
"id" CHAR(26) NOT NULL,
"code" VARCHAR(64) NOT NULL,
"name" VARCHAR(64) NOT NULL,
"parent_id" CHAR(26),
"leader_user_id" CHAR(26),
"sort_order" INTEGER NOT NULL DEFAULT 0,
"status" VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "departments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" CHAR(26) NOT NULL,
"account" VARCHAR(64) NOT NULL,
"email" VARCHAR(128),
"name" VARCHAR(64) NOT NULL,
"phone" VARCHAR(20) NOT NULL,
"password_hash" VARCHAR(255) NOT NULL,
"department_id" CHAR(26) NOT NULL,
"job_title" VARCHAR(64),
"status" "UserStatus" NOT NULL DEFAULT 'ACTIVE',
"must_change_password" BOOLEAN NOT NULL DEFAULT true,
"last_login_at" TIMESTAMPTZ(3),
"last_login_ip" VARCHAR(64),
"disabled_at" TIMESTAMPTZ(3),
"remark" VARCHAR(200),
"created_by" CHAR(26),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" CHAR(26) NOT NULL,
"code" VARCHAR(64) NOT NULL,
"name" VARCHAR(64) NOT NULL,
"description" VARCHAR(255) NOT NULL DEFAULT '',
"is_system" BOOLEAN NOT NULL DEFAULT false,
"status" "RoleStatus" NOT NULL DEFAULT 'ACTIVE',
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"user_id" CHAR(26) NOT NULL,
"role_id" CHAR(26) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("user_id","role_id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"role_id" CHAR(26) NOT NULL,
"permission_code" VARCHAR(64) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_code")
);
-- CreateTable
CREATE TABLE "refresh_tokens" (
"id" CHAR(26) NOT NULL,
"user_id" CHAR(26) NOT NULL,
"family_id" CHAR(26) NOT NULL,
"token_hash" VARCHAR(64) NOT NULL,
"user_agent" VARCHAR(255),
"ip" VARCHAR(64),
"expires_at" TIMESTAMPTZ(3) NOT NULL,
"revoked_at" TIMESTAMPTZ(3),
"replaced_by_id" CHAR(26),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "departments_code_key" ON "departments"("code");
-- CreateIndex
CREATE INDEX "departments_parent_id_sort_order_idx" ON "departments"("parent_id", "sort_order");
-- CreateIndex
CREATE UNIQUE INDEX "departments_parent_id_name_key" ON "departments"("parent_id", "name");
-- CreateIndex
CREATE UNIQUE INDEX "users_account_key" ON "users"("account");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_phone_key" ON "users"("phone");
-- CreateIndex
CREATE INDEX "users_department_id_status_idx" ON "users"("department_id", "status");
-- CreateIndex
CREATE INDEX "users_status_last_login_at_idx" ON "users"("status", "last_login_at");
-- CreateIndex
CREATE UNIQUE INDEX "roles_code_key" ON "roles"("code");
-- CreateIndex
CREATE INDEX "user_roles_role_id_idx" ON "user_roles"("role_id");
-- CreateIndex
CREATE INDEX "role_permissions_permission_code_idx" ON "role_permissions"("permission_code");
-- CreateIndex
CREATE UNIQUE INDEX "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash");
-- CreateIndex
CREATE INDEX "refresh_tokens_user_id_expires_at_idx" ON "refresh_tokens"("user_id", "expires_at");
-- CreateIndex
CREATE INDEX "refresh_tokens_family_id_idx" ON "refresh_tokens"("family_id");
-- AddForeignKey
ALTER TABLE "departments" ADD CONSTRAINT "departments_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "departments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "departments" ADD CONSTRAINT "departments_leader_user_id_fkey" FOREIGN KEY ("leader_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_department_id_fkey" FOREIGN KEY ("department_id") REFERENCES "departments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+126
View File
@@ -47,6 +47,132 @@ model IdempotencyKey {
@@map("idempotency_keys")
}
/// 部门:两级组织(一级部门 → 小组),父子用 parent_id 关联(A1 identity)
model Department {
id String @id @db.Char(26)
code String @unique @db.VarChar(64)
name String @db.VarChar(64)
parentId String? @map("parent_id") @db.Char(26)
leaderUserId String? @map("leader_user_id") @db.Char(26)
sortOrder Int @default(0) @map("sort_order")
status String @default("ACTIVE") @db.VarChar(16)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
parent Department? @relation("DepartmentTree", fields: [parentId], references: [id])
children Department[] @relation("DepartmentTree")
leader User? @relation("DepartmentLeader", fields: [leaderUserId], references: [id])
users User[] @relation("UserDepartment")
@@unique([parentId, name])
@@index([parentId, sortOrder])
@@map("departments")
}
enum UserStatus {
ACTIVE
DISABLED
}
/// 用户:登录账号唯一、手机号唯一;生命周期只停用不删除(PRD R-02)
model User {
id String @id @db.Char(26)
account String @unique @db.VarChar(64)
email String? @unique @db.VarChar(128)
name String @db.VarChar(64)
phone String @unique @db.VarChar(20)
passwordHash String @map("password_hash") @db.VarChar(255)
departmentId String @map("department_id") @db.Char(26)
jobTitle String? @map("job_title") @db.VarChar(64)
status UserStatus @default(ACTIVE)
mustChangePassword Boolean @default(true) @map("must_change_password")
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(3)
lastLoginIp String? @map("last_login_ip") @db.VarChar(64)
disabledAt DateTime? @map("disabled_at") @db.Timestamptz(3)
remark String? @db.VarChar(200)
createdBy String? @map("created_by") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
department Department @relation("UserDepartment", fields: [departmentId], references: [id])
roles UserRole[]
refreshTokens RefreshToken[]
ledDepartments Department[] @relation("DepartmentLeader")
@@index([departmentId, status])
@@index([status, lastLoginAt])
@@map("users")
}
enum RoleStatus {
ACTIVE
DISABLED
}
/// 角色:A1 只做绑定多选,权限点配置属 A2(此处先存角色-权限点)
model Role {
id String @id @db.Char(26)
code String @unique @db.VarChar(64)
name String @db.VarChar(64)
description String @default("") @db.VarChar(255)
isSystem Boolean @default(false) @map("is_system")
status RoleStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
users UserRole[]
permissions RolePermission[]
@@map("roles")
}
/// 用户-角色绑定(一人多角色)
model UserRole {
userId String @map("user_id") @db.Char(26)
roleId String @map("role_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([userId, roleId])
@@index([roleId])
@@map("user_roles")
}
/// 角色-权限点:权限点取 @lingxi/shared PERMISSION_CODES,每请求实时解析(不缓存旧权限)
model RolePermission {
roleId String @map("role_id") @db.Char(26)
permissionCode String @map("permission_code") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([roleId, permissionCode])
@@index([permissionCode])
@@map("role_permissions")
}
/// 刷新令牌:jti 作主键、只存哈希;旋转时旧 token 立刻吊销并记录 replaced_by_id
model RefreshToken {
id String @id @db.Char(26)
userId String @map("user_id") @db.Char(26)
familyId String @map("family_id") @db.Char(26)
tokenHash String @unique @map("token_hash") @db.VarChar(64)
userAgent String? @map("user_agent") @db.VarChar(255)
ip String? @db.VarChar(64)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
replacedById String? @map("replaced_by_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, expiresAt])
@@index([familyId])
@@map("refresh_tokens")
}
/// 系统配置:阈值 / 上限类数字唯一存放处(PRD R-26),带版本与变更审计
model SystemConfig {
id String @id @db.Char(26)
+392 -10
View File
@@ -1,13 +1,13 @@
// 灵犀 · 种子数据(A0)
// 灵犀 · 种子数据(A1)
// 原则:
// 1) 只 seed 已建表的系统配置(system_configs),不提前创建 A1+ 业务表;
// 2) 幂等:按 config_key upsert,已存在的配置不覆盖(避免冲掉人工调整值);
// 3) 超管、角色、字典、电商行业包占位等种子依赖 A1/A3/A4/B0 的建表,见 prisma/SEED.md。
// 1) 幂等:按业务唯一键 upsert,重复执行不重复插入;已存在的密码 / 人工调整值不被覆盖;
// 2) 角色-权限点、用户-角色为种子可重建映射:先清后建(限定在种子角色 / 种子用户范围内);
// 3) 超管、部门、示例用户口径见 prisma/SEED.md。
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { PrismaClient } from '@prisma/client';
import { PrismaClient, type UserStatus } from '@prisma/client';
import { config as loadDotenv } from 'dotenv';
for (const file of ['.env.local', '.env']) {
@@ -33,6 +33,10 @@ function ulid(): string {
return `${timeChars.join('')}${randomChars.join('')}`;
}
/** 示例账号统一初始密码:lingxi2026(Argon2id,首登强制修改) */
const SEED_PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
interface SeedConfig {
key: string;
value: string | number | boolean;
@@ -40,7 +44,7 @@ interface SeedConfig {
description: string;
}
const systemConfigs: SeedConfig[] = [
const SYSTEM_CONFIGS: SeedConfig[] = [
{
key: 'platform.name',
value: '灵犀',
@@ -85,8 +89,282 @@ const systemConfigs: SeedConfig[] = [
},
];
async function main(): Promise<void> {
for (const item of systemConfigs) {
interface SeedDepartment {
code: string;
name: string;
parentCode: string | null;
leaderAccount: string | null;
sortOrder: number;
}
const DEPARTMENTS: SeedDepartment[] = [
{
code: 'content',
name: '内容生产中心',
parentCode: null,
leaderAccount: 'chen.mo',
sortOrder: 1,
},
{ code: 'delivery', name: '交付中心', parentCode: null, leaderAccount: 'gao.cen', sortOrder: 2 },
{
code: 'tech',
name: '平台技术部',
parentCode: null,
leaderAccount: 'shen.zhiyuan',
sortOrder: 3,
},
{
code: 'content-g1',
name: '素材一组',
parentCode: 'content',
leaderAccount: null,
sortOrder: 1,
},
{
code: 'content-g2',
name: '素材二组',
parentCode: 'content',
leaderAccount: null,
sortOrder: 2,
},
{ code: 'content-g3', name: '设计组', parentCode: 'content', leaderAccount: null, sortOrder: 3 },
{
code: 'delivery-cs',
name: '客户成功组',
parentCode: 'delivery',
leaderAccount: null,
sortOrder: 1,
},
{ code: 'tech-ai', name: 'AI 工程组', parentCode: 'tech', leaderAccount: null, sortOrder: 1 },
{ code: 'tech-ops', name: '平台运维组', parentCode: 'tech', leaderAccount: null, sortOrder: 2 },
];
interface SeedRole {
code: string;
name: string;
description: string;
isSystem: boolean;
permissions: string[];
}
const ROLES: SeedRole[] = [
{
code: 'super_admin',
name: '超级管理员',
description: '内置角色,拥有全部权限点',
isSystem: true,
permissions: [
'script:read',
'script:write',
'script:review',
'material:read',
'material:write',
'material:render',
'material:review',
'asset:read',
'asset:write',
'delivery:read',
'delivery:export',
'customer:read',
'customer:manage',
'customer_field:approve',
'user:view',
'user:manage',
'role:manage',
'industry:manage',
'industry:publish',
'dict:manage',
'form:design',
'ai:configure',
'system:configure',
'audit:read',
'audit:export',
] as string[],
},
{
code: 'platform_admin',
name: '平台管理员',
description: '组织、角色与平台配置管理',
isSystem: false,
permissions: [
'user:view',
'user:manage',
'role:manage',
'system:configure',
'audit:read',
'industry:manage',
'dict:manage',
'form:design',
'customer:read',
],
},
{
code: 'material_operator',
name: '素材运营',
description: '素材生产、快筛、导出与归档',
isSystem: false,
permissions: [
'script:read',
'script:write',
'material:read',
'material:write',
'material:render',
'asset:read',
'asset:write',
'delivery:read',
],
},
{
code: 'designer',
name: '设计师',
description: '素材设计与资产维护',
isSystem: false,
permissions: ['material:read', 'material:write', 'asset:read', 'asset:write'],
},
{
code: 'copywriter',
name: '文案策划',
description: '脚本撰写与要素卡维护',
isSystem: false,
permissions: ['script:read', 'script:write'],
},
{
code: 'reviewer',
name: '审核员',
description: '脚本与素材审核',
isSystem: false,
permissions: ['script:read', 'script:review', 'material:read', 'material:review'],
},
{
code: 'ai_engineer',
name: 'AI 工程师',
description: '模型接入与生成链路维护',
isSystem: false,
permissions: ['material:read', 'material:render', 'system:configure'],
},
{
code: 'account_owner',
name: '客户负责人',
description: '客户资料查看、脚本审核与交付验收',
isSystem: false,
permissions: ['customer:read', 'customer:manage', 'delivery:read', 'script:review'],
},
];
interface SeedUser {
name: string;
account: string;
phone: string;
jobTitle: string;
departmentCode: string;
roleCodes: string[];
status: UserStatus;
}
const USERS: SeedUser[] = [
{
name: '灵犀管理员',
account: 'admin',
phone: '13000000001',
jobTitle: '平台管理员',
departmentCode: 'tech',
roleCodes: ['super_admin'],
status: 'ACTIVE',
},
{
name: '陈默',
account: 'chen.mo',
phone: '13866202046',
jobTitle: '内容生产中心主管',
departmentCode: 'content-g1',
roleCodes: ['material_operator', 'account_owner'],
status: 'ACTIVE',
},
{
name: '林清',
account: 'lin.qing',
phone: '13912347712',
jobTitle: '设计师',
departmentCode: 'content-g3',
roleCodes: ['designer'],
status: 'ACTIVE',
},
{
name: '周岸',
account: 'zhou.an',
phone: '13755205520',
jobTitle: '文案策划',
departmentCode: 'content-g2',
roleCodes: ['copywriter'],
status: 'ACTIVE',
},
{
name: '赵一诺',
account: 'zhao.yinuo',
phone: '13633183188',
jobTitle: '审核员',
departmentCode: 'content-g2',
roleCodes: ['reviewer'],
status: 'ACTIVE',
},
{
name: '高岑',
account: 'gao.cen',
phone: '18644024402',
jobTitle: '交付中心主管',
departmentCode: 'delivery-cs',
roleCodes: ['account_owner'],
status: 'ACTIVE',
},
{
name: '苏禾',
account: 'su.he',
phone: '18877657765',
jobTitle: 'AI 工程师',
departmentCode: 'tech-ai',
roleCodes: ['ai_engineer'],
status: 'ACTIVE',
},
{
name: '沈知远',
account: 'shen.zhiyuan',
phone: '13300000071',
jobTitle: '平台技术部主管',
departmentCode: 'tech',
roleCodes: ['super_admin'],
status: 'ACTIVE',
},
{
name: '江岚',
account: 'jiang.lan',
phone: '15933203320',
jobTitle: '平台管理员',
departmentCode: 'tech-ops',
roleCodes: ['platform_admin'],
status: 'ACTIVE',
},
{
name: '唐嘉',
account: 'tang.jia',
phone: '13566416641',
jobTitle: '素材运营',
departmentCode: 'content-g1',
roleCodes: ['material_operator'],
status: 'DISABLED',
},
{
name: '何岸',
account: 'he.an',
phone: '18711931193',
jobTitle: '客户负责人',
departmentCode: 'delivery-cs',
roleCodes: ['account_owner'],
status: 'DISABLED',
},
];
async function seedConfigs(): Promise<void> {
for (const item of SYSTEM_CONFIGS) {
await prisma.systemConfig.upsert({
where: { configKey: item.key },
update: {},
@@ -99,10 +377,114 @@ async function main(): Promise<void> {
},
});
}
console.log(`[seed] system_configs 就绪:${String(systemConfigs.length)} 项(幂等)`);
console.log(`[seed] system_configs 就绪:${String(SYSTEM_CONFIGS.length)} 项(幂等)`);
}
main()
async function seedDepartments(): Promise<void> {
for (const item of DEPARTMENTS) {
const parent =
item.parentCode === null
? null
: await prisma.department.findUnique({ where: { code: item.parentCode } });
await prisma.department.upsert({
where: { code: item.code },
update: {},
create: {
id: ulid(),
code: item.code,
name: item.name,
parentId: parent?.id ?? null,
sortOrder: item.sortOrder,
},
});
}
console.log(`[seed] departments 就绪:${String(DEPARTMENTS.length)} 个(幂等)`);
}
async function seedRoles(): Promise<void> {
for (const item of ROLES) {
const role = await prisma.role.upsert({
where: { code: item.code },
update: {},
create: {
id: ulid(),
code: item.code,
name: item.name,
description: item.description,
isSystem: item.isSystem,
status: 'ACTIVE',
},
});
await prisma.rolePermission.deleteMany({ where: { roleId: role.id } });
await prisma.rolePermission.createMany({
data: item.permissions.map((permissionCode) => ({ roleId: role.id, permissionCode })),
skipDuplicates: true,
});
}
console.log(`[seed] roles 就绪:${String(ROLES.length)} 个 + 权限点映射(幂等)`);
}
async function seedUsers(): Promise<void> {
for (const item of USERS) {
const department = await prisma.department.findUnique({ where: { code: item.departmentCode } });
if (department === null) {
throw new Error(`部门不存在:${item.departmentCode}`);
}
const user = await prisma.user.upsert({
where: { account: item.account },
update: {},
create: {
id: ulid(),
name: item.name,
account: item.account,
email: `${item.account}@lingxi.cn`,
phone: item.phone,
passwordHash: SEED_PASSWORD_HASH,
departmentId: department.id,
jobTitle: item.jobTitle,
status: item.status,
mustChangePassword: true,
...(item.status === 'DISABLED' ? { disabledAt: new Date() } : {}),
},
});
await prisma.userRole.deleteMany({ where: { userId: user.id } });
for (const code of item.roleCodes) {
const role = await prisma.role.findUnique({ where: { code } });
if (role === null) throw new Error(`角色不存在:${code}`);
await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
}
}
console.log(
`[seed] users 就绪:${String(USERS.length)} 个(幂等,初始密码 lingxi2026,首登强制修改)`,
);
}
async function seedDepartmentLeaders(): Promise<void> {
for (const item of DEPARTMENTS) {
if (item.leaderAccount === null) continue;
const [department, leader] = await Promise.all([
prisma.department.findUnique({ where: { code: item.code } }),
prisma.user.findUnique({ where: { account: item.leaderAccount } }),
]);
if (department !== null && leader !== null) {
await prisma.department.update({
where: { id: department.id },
data: { leaderUserId: leader.id },
});
}
}
console.log('[seed] 部门负责人就绪(幂等)');
}
async function main(): Promise<void> {
await seedConfigs();
await seedDepartments();
await seedRoles();
await seedUsers();
await seedDepartmentLeaders();
}
void main()
.catch((error: unknown) => {
console.error('[seed] 失败:', error);
process.exitCode = 1;