feat(a0): 完成工程骨架与契约基线
ci / 质量门禁 (push) Canceled after 0s

- 建立 pnpm workspace + Turborepo 单仓、ESLint 9/Prettier/commitlint/husky 门禁
- NestJS + Fastify API:统一错误体、trace_id、Zod 管道、/health 真实依赖探活、OpenAPI 3.1 契约
- Prisma 基线:outbox_events/idempotency_keys/system_configs 与首版迁移、幂等种子
- shared 契约包:错误码、权限点、ULID、游标分页、命名/时间工具
- ui 包与 Web 壳层:Semi + Remix 图标统一出口、Cloud Blue 视觉、登录/工作台/错误页
- worker 骨架、Playwright 冒烟、Gitea CI、OpenAPI 快照与 oasdiff 回退脚本
- 补充 A0 实施报告与本地开发说明
This commit is contained in:
Stev_Wang committed 2026-11-02 17:01:50 +08:00
1 parent 9c3ab9768a
commit 04f49690f4
155 files changed
+18402 -28

No files matched your search

+17
View File
@@ -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");
+3
View File
@@ -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"
+64
View File
@@ -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
View File
@@ -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();
});