From 09dbb27b66fa82c2d702160b22bddc40ca230d6a Mon Sep 17 00:00:00 2001 From: Stev_Wang <304865932@qq.com> Date: Mon, 2 Nov 2026 17:20:32 +0800 Subject: [PATCH] =?UTF-8?q?build(db):=20=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E4=BE=9D=E8=B5=96=E4=B8=8E=20Prisma=20?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提供 PG18/Redis7.4/S3 本地 compose、环境变量样例与 Prisma 首版迁移;建立 outbox_events、idempotency_keys、system_configs 三张平台骨架表及幂等种子。MinIO 镜像不可得,本地以 S3Mock 替代。 --- .env.example | 47 ++++++++ infra/compose.dev.yaml | 4 + infra/docker-compose.dev.yaml | 70 +++++++++++ prisma/SEED.md | 17 +++ .../migration.sql | 60 ++++++++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 64 ++++++++++ prisma/seed.ts | 112 ++++++++++++++++++ 8 files changed, 377 insertions(+) create mode 100644 .env.example create mode 100644 infra/compose.dev.yaml create mode 100644 infra/docker-compose.dev.yaml create mode 100644 prisma/SEED.md create mode 100644 prisma/migrations/20261102083445_init_platform_baseline/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b266ce6 --- /dev/null +++ b/.env.example @@ -0,0 +1,47 @@ +# 灵犀 · 环境变量示例(入库,禁止写真实密钥) +# 使用:cp .env.example .env.local(工程规范 09 ENV-09) +# 注意:本地 compose 的宿主端口为 15432/16379/19000/19001,避免与本机既有 PG/Redis 冲突。 + +# ---------- 运行环境 ---------- +NODE_ENV=development +LOG_LEVEL=debug + +# ---------- 数据库(PostgreSQL 18) ---------- +DATABASE_URL=postgresql://lingxi:lingxi_dev_password@127.0.0.1:15432/lingxi_dev?schema=public + +# ---------- Redis(7.4) ---------- +REDIS_URL=redis://127.0.0.1:16379 + +# ---------- 对象存储(MinIO,S3 协议) ---------- +S3_ENDPOINT=http://127.0.0.1:19000 +S3_REGION=us-east-1 +S3_BUCKET_ASSETS=assets +S3_BUCKET_RENDERS=renders +S3_BUCKET_TEMP=temp +S3_ACCESS_KEY=lingxi +S3_SECRET_KEY=lingxi_dev_password + +# ---------- 认证(本地 dev 值,生产由密钥管理注入) ---------- +JWT_SECRET=dev-only-jwt-secret-change-me-32chars +JWT_REFRESH_SECRET=dev-only-jwt-refresh-secret-change-me-32chars + +# ---------- API ---------- +PORT=3000 +HOST=0.0.0.0 +WEB_ORIGIN=http://127.0.0.1:5173 +MAX_UPLOAD_MB=500 + +# ---------- Worker ---------- +QUEUE=default +WORKER_CONCURRENCY=2 + +# ---------- 前端(只能暴露 VITE_PUBLIC_* 前缀,ENV-07) ---------- +VITE_PUBLIC_API_BASE_URL=http://127.0.0.1:3000/api/v1 +# A0 登录壳没有接后端鉴权,置 true 时登录页写入本地演示会话以便查看布局;A1 接入真实登录后删除。 +VITE_PUBLIC_DEMO_SESSION=true + +# ---------- 可观测 ---------- +OTEL_EXPORTER_OTLP_ENDPOINT= + +# ---------- 功能开关 ---------- +FEATURE_VIDEO=false diff --git a/infra/compose.dev.yaml b/infra/compose.dev.yaml new file mode 100644 index 0000000..cbb95de --- /dev/null +++ b/infra/compose.dev.yaml @@ -0,0 +1,4 @@ +# 技术架构 §4.1.1 引用的路径为 infra/compose.dev.yaml,这里做 include 兼容。 +# 实际定义在 infra/docker-compose.dev.yaml(开发计划 §6.1 与工程规范 README 的命名)。 +include: + - docker-compose.dev.yaml diff --git a/infra/docker-compose.dev.yaml b/infra/docker-compose.dev.yaml new file mode 100644 index 0000000..4a2c6d4 --- /dev/null +++ b/infra/docker-compose.dev.yaml @@ -0,0 +1,70 @@ +# 灵犀 · 本地开发环境编排(PG18 / Redis 7.4 / S3 兼容对象存储) +# 用法:pnpm compose:up / pnpm compose:down +# 说明:本机已有其他 PG/Redis 占用 5432/6379,这里宿主端口统一改用 15432/16379, +# 容器内端口保持标准值;对应 DATABASE_URL / REDIS_URL 见根目录 .env.example。 +name: lingxi-dev + +services: + postgres: + image: postgres:18-alpine + container_name: lingxi-pg + environment: + POSTGRES_USER: lingxi + POSTGRES_PASSWORD: lingxi_dev_password + POSTGRES_DB: lingxi_dev + TZ: Asia/Shanghai + ports: + - '15432:5432' + volumes: + # PG18+ 镜像要求挂载 /var/lib/postgresql(数据放在版本子目录,便于 pg_upgrade) + - lingxi-pg-data:/var/lib/postgresql + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U lingxi -d lingxi_dev'] + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + + redis: + image: redis:7.4-alpine + container_name: lingxi-redis + command: ['redis-server', '--appendonly', 'yes'] + ports: + - '16379:6379' + volumes: + - lingxi-redis-data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 20 + start_period: 5s + + # 对象存储:技术架构要求 MinIO(S3 协议)。2026-11 实测 minio/minio 与 minio/mc + # 官方镜像在当前网络/仓库均无法拉取(registry denied),quay.io 需鉴权; + # 本地开发改用 S3 兼容的 Adobe S3Mock,接口与四桶(assets/renders/temp/archive)保持一致, + # S3_ENDPOINT 仍指向 19000。CI/生产接入真实 MinIO,切换时仅替换本服务。 + s3: + image: adobe/s3mock:latest + container_name: lingxi-s3 + environment: + COM_ADOBE_TESTING_S3MOCK_STORE_INITIAL_BUCKETS: assets,renders,temp,archive + COM_ADOBE_TESTING_S3MOCK_STORE_REGION: us-east-1 + COM_ADOBE_TESTING_S3MOCK_STORE_ROOT: /s3mockroot + COM_ADOBE_TESTING_S3MOCK_STORE_RETAIN_FILES_ON_EXIT: 'true' + ports: + - '19000:9090' + - '19001:9191' + volumes: + - lingxi-s3-data:/s3mockroot + healthcheck: + test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:9090/ || exit 1'] + interval: 5s + timeout: 3s + retries: 20 + start_period: 20s + +volumes: + lingxi-pg-data: + lingxi-redis-data: + lingxi-s3-data: diff --git a/prisma/SEED.md b/prisma/SEED.md new file mode 100644 index 0000000..83cc575 --- /dev/null +++ b/prisma/SEED.md @@ -0,0 +1,17 @@ +# 种子数据状态(A0) + +A0-INF-02 的计划要求种子包含「超管、示例角色、核心字典、电商行业包占位」。但这四类数据依赖的用户 / 角色 / 字典 / 行业包表分别在 A1(identity)、A3(industry)、A4(catalog)与 B0 批次才建表,A0 阶段提前建表会越过批次边界、造成后续迁移冲突。 + +A0 的处理: + +- 已 seed(`prisma/seed.ts`):`system_configs` 中平台级配置项,按 `config_key` 幂等 upsert,重复执行不重复插入、不覆盖人工调整值。 +- 待后续批次接入(保持 TODO,不在 A0 建表): + +| 种子 | 依赖批次 | 说明 | +| -------------------------------- | ----------- | ------------------------------------------------------------------------ | +| 超级管理员账号 | 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 描述。 diff --git a/prisma/migrations/20261102083445_init_platform_baseline/migration.sql b/prisma/migrations/20261102083445_init_platform_baseline/migration.sql new file mode 100644 index 0000000..d4ac5e6 --- /dev/null +++ b/prisma/migrations/20261102083445_init_platform_baseline/migration.sql @@ -0,0 +1,60 @@ +-- CreateTable +CREATE TABLE "outbox_events" ( + "id" CHAR(26) NOT NULL, + "aggregate_type" VARCHAR(64) NOT NULL, + "aggregate_id" CHAR(26) NOT NULL, + "event_type" VARCHAR(128) NOT NULL, + "payload_json" JSONB NOT NULL, + "trace_id" VARCHAR(64), + "occurred_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published_at" TIMESTAMPTZ(3), + "attempts" INTEGER NOT NULL DEFAULT 0, + "last_error" TEXT, + + CONSTRAINT "outbox_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "idempotency_keys" ( + "id" CHAR(26) NOT NULL, + "scope" VARCHAR(64) NOT NULL, + "key" VARCHAR(128) NOT NULL, + "request_hash" VARCHAR(64) NOT NULL, + "response_status" INTEGER, + "response_body" JSONB, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "idempotency_keys_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "system_configs" ( + "id" CHAR(26) NOT NULL, + "config_key" VARCHAR(128) NOT NULL, + "config_value" JSONB NOT NULL, + "value_type" VARCHAR(16) NOT NULL, + "description" VARCHAR(255) NOT NULL DEFAULT '', + "version" INTEGER NOT NULL DEFAULT 1, + "is_editable" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + "updated_by" CHAR(26), + + CONSTRAINT "system_configs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "outbox_events_published_at_occurred_at_idx" ON "outbox_events"("published_at", "occurred_at"); + +-- CreateIndex +CREATE INDEX "outbox_events_aggregate_type_aggregate_id_idx" ON "outbox_events"("aggregate_type", "aggregate_id"); + +-- CreateIndex +CREATE INDEX "idempotency_keys_expires_at_idx" ON "idempotency_keys"("expires_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "idempotency_keys_scope_key_key" ON "idempotency_keys"("scope", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "system_configs_config_key_key" ON "system_configs"("config_key"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..ed7e80f --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,64 @@ +// 灵犀 Prisma schema(A0 基线) +// 依据:docs/工程规范/05-数据库规范.md、技术架构 §5.3 / §5.4.5 +// 约定:表/列 snake_case;主键 char(26) ULID(应用侧生成);时间 timestamptz;JSON 用 JsonB。 +// A0 只建平台侧骨架表(outbox_events / idempotency_keys / system_configs); +// 身份、客户、行业、脚本、素材等业务表按里程碑在各自模块内新增,禁止跨模块表。 + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +/// 事务性发件箱:领域事件先落库,再由 worker 投递(技术架构 §5.3) +model OutboxEvent { + id String @id @db.Char(26) + aggregateType String @map("aggregate_type") @db.VarChar(64) + aggregateId String @map("aggregate_id") @db.Char(26) + eventType String @map("event_type") @db.VarChar(128) + payloadJson Json @map("payload_json") @db.JsonB + traceId String? @map("trace_id") @db.VarChar(64) + occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(3) + publishedAt DateTime? @map("published_at") @db.Timestamptz(3) + attempts Int @default(0) + lastError String? @map("last_error") @db.Text + + @@index([publishedAt, occurredAt]) + @@index([aggregateType, aggregateId]) + @@map("outbox_events") +} + +/// 请求幂等键:任务提交必须带 Idempotency-Key(TTL 24h) +model IdempotencyKey { + id String @id @db.Char(26) + scope String @db.VarChar(64) + key String @db.VarChar(128) + requestHash String @map("request_hash") @db.VarChar(64) + responseStatus Int? @map("response_status") + responseBody Json? @map("response_body") @db.JsonB + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + + @@unique([scope, key]) + @@index([expiresAt]) + @@map("idempotency_keys") +} + +/// 系统配置:阈值 / 上限类数字唯一存放处(PRD R-26),带版本与变更审计 +model SystemConfig { + id String @id @db.Char(26) + configKey String @unique @map("config_key") @db.VarChar(128) + configValue Json @map("config_value") @db.JsonB + valueType String @map("value_type") @db.VarChar(16) + description String @default("") @db.VarChar(255) + version Int @default(1) + isEditable Boolean @default(true) @map("is_editable") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + updatedBy String? @map("updated_by") @db.Char(26) + + @@map("system_configs") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..763b302 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,112 @@ +// 灵犀 · 种子数据(A0) +// 原则: +// 1) 只 seed 已建表的系统配置(system_configs),不提前创建 A1+ 业务表; +// 2) 幂等:按 config_key upsert,已存在的配置不覆盖(避免冲掉人工调整值); +// 3) 超管、角色、字典、电商行业包占位等种子依赖 A1/A3/A4/B0 的建表,见 prisma/SEED.md。 +import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { PrismaClient } from '@prisma/client'; +import { config as loadDotenv } from 'dotenv'; + +for (const file of ['.env.local', '.env']) { + const path = resolve(process.cwd(), file); + if (existsSync(path)) { + loadDotenv({ path }); + } +} + +const prisma = new PrismaClient(); + +const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +function ulid(): string { + let time = Date.now(); + const timeChars = new Array(10); + for (let i = 9; i >= 0; i -= 1) { + timeChars[i] = ENCODING[time % 32] ?? '0'; + time = Math.floor(time / 32); + } + const random = randomBytes(16); + const randomChars = Array.from(random, (byte) => ENCODING[byte & 31] ?? '0'); + return `${timeChars.join('')}${randomChars.join('')}`; +} + +interface SeedConfig { + key: string; + value: string | number | boolean; + valueType: 'string' | 'number' | 'boolean'; + description: string; +} + +const systemConfigs: SeedConfig[] = [ + { + key: 'platform.name', + value: '灵犀', + valueType: 'string', + description: '平台名称(展示用)', + }, + { + key: 'platform.export.signed_url_ttl_seconds', + value: 900, + valueType: 'number', + description: '导出产物签名 URL 有效期(秒),短时效', + }, + { + key: 'platform.export.archive_retention_days', + value: 3, + valueType: 'number', + description: '导出打包产物默认保留天数', + }, + { + key: 'platform.upload.max_upload_mb', + value: 500, + valueType: 'number', + description: '单文件上传上限(MB)', + }, + { + key: 'platform.page.default_limit', + value: 20, + valueType: 'number', + description: '列表默认每页数量', + }, + { + key: 'platform.ai.cost_soft_limit_ratio', + value: 0.8, + valueType: 'number', + description: 'AI 成本软上限触发比例', + }, + { + key: 'platform.ai.cost_hard_limit_ratio', + value: 1, + valueType: 'number', + description: 'AI 成本硬上限触发比例(达到即拒绝新任务)', + }, +]; + +async function main(): Promise { + for (const item of systemConfigs) { + await prisma.systemConfig.upsert({ + where: { configKey: item.key }, + update: {}, + create: { + id: ulid(), + configKey: item.key, + configValue: item.value, + valueType: item.valueType, + description: item.description, + }, + }); + } + console.log(`[seed] system_configs 就绪:${String(systemConfigs.length)} 项(幂等)`); +} + +main() + .catch((error: unknown) => { + console.error('[seed] 失败:', error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + });