feat(ci): 搭建 A0 工程骨架与契约基线 #2
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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 描述。
|
||||||
@@ -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");
|
||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
+112
@@ -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<string>(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<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user