首页 / 文章 / 将内容视作代码:从 Git 到 PostgreSQL 的数据初始化流程

将内容视作代码:从 Git 到 PostgreSQL 的数据初始化流程

介绍如何用 Git 监控的 JSON、Zod 验证以及 Prisma upsert 功能来替代传统 CMS,从而安全地将结构化内容导入 Postgres。

2258 词

试想构建一个包含选择题、嵌入式代码片段、解释说明以及难度等级的测验应用。这类内容会频繁更新,但并不需要在不合适的时间进行实时编辑。

显而易见的首选可能是 Sanity 或 Strapi 这类内容管理系统。但在选用之前,先明确实际需求会更有帮助:

  • 对内容所做的每一次修改都要有完整的变更历史记录
  • 能够在内容正式上线前审查这些更改
  • 通过验证机制在构建阶段发现问题,而非等到正式上线后
  • MVP 阶段无需额外的基础设施
  • 工作流程应与现有的代码发布方式保持一致

考虑到这些需求,直接将内容存储在 Git 中比添加内容管理系统更为合理。

该方案使用JSON文件、Zod模式、Prisma种子脚本以及PostgreSQL作为运行时存储。整个流程如下:

JSON → Zod → 种子数据插入/更新 → Postgres → API

刻意设计得较为简单,因为流程越朴素就越可靠。

为何不“直接使用CMS”?

当非技术人员需要每天发布内容、当系统需要草稿状态和权限角色,或是数据结构会不可预测地变化时,CMS平台才能发挥其价值。

但对于由工程师自行编写的结构化内容——如题库、种子数据、用户引导流程、定价层级等——引入CMS通常会带来以下问题:

  • 又一项需要托管和保障安全的服务
  • 另一套必须与应用程序保持一致的架构规范
  • 又一个可能让无效数据混入的漏洞
  • 又一次从编辑器中切换出去的操作
  • 实际上需要的并非发布平台,而是一个内容处理流程:

    作者 → 验证 → 审核 → 部署 → 初始化 → 提供服务

    Git已经处理了前四个步骤,唯一缺失的是一种可靠的内容导入数据库的方法。

    架构设计

    content/
      questions/
        javascript/
          easy.json
          medium.json
          hard.json
        html/
          easy.json
    packages/db/
      prisma/schema.prisma
      src/seed.ts              ← read, validate, upsert
    packages/shared/
      schemas/question.ts      ← Zod contract
    scripts/
      validate-content.ts      ← CI, no DB required
    
    | Layer        | Responsibility                      |
    |--------------|-------------------------------------|
    | JSON         | Human-editable source of truth      |
    | Zod          | Runtime validation + inferred types |
    | Prisma seed  | Idempotent import into the database |
    

    这里有一条不可违背的规则:在生产环境中,应用程序绝不能在运行时读取JSON文件。JSON仅作为部署时的输入存在。Postgres依然是实际处理查询的层。

    这样既能享受Git的工作流优势,又不会让数据库变成单纯的文件代理工具。

    第一步:从Zod开始,而非JSON

    在编写任何内容之前,先定义其必须满足的规范。

    import { z } from 'zod';
    
    export enum Topic {
      JavaScript = 'JAVASCRIPT',
      HTML = 'HTML',
      TypeScript = 'TYPESCRIPT',
    }
    
    export enum Difficulty {
      Easy = 'EASY',
      Medium = 'MEDIUM',
      Hard = 'HARD',
    }
    
    export const questionSchema = z
      .object({
        id: z.string().min(1), // stable slug: js-closures-loop-001
        topic: z.nativeEnum(Topic),
        subtopic: z.string().min(1),
        difficulty: z.nativeEnum(Difficulty),
        text: z.string().min(1),
        codeSnippet: z.string().nullable().optional(),
        options: z.array(z.string().min(1)).min(2),
        correctOptionIndex: z.number().int().min(0),
        explanation: z.string().min(1),
      })
      .refine((q) => q.correctOptionIndex < q.options.length, {
        message: 'correctOptionIndex must point to a valid option',
      });
    
    export const questionsFileSchema = z.array(questionSchema);
    export type QuestionContent = z.infer<typeof questionSchema>;
    

    有几点刻意的设计选择尤为突出:

    • id字段直接存在于内容文件中——这正是确保重新部署安全性的关键。由数据库生成的主键仅属于实现细节;像js-closures-loop-001这样的稳定标识符才是用户保存的进度所依赖的实际要素。
    • 使用枚举而非原始字符串,从而避免jsJSjavascript这类大小写不一致的情况出现在不同文件中。
    • .refine()能够处理涉及多个字段的验证规则——这是简单的min()约束无法实现的,例如确保答案索引保持在限定范围内。
    • 每个架构都将整个JSON文件视为一个数组进行验证,而非逐条记录验证。

    这样一来,你的内容就有了具有法律约束力的合同,而不仅仅是一份存放在某处却无人阅读的约定。

    第2步:编写简单的JSON

    [
      {
        "id": "js-closures-loop-001",
        "topic": "JAVASCRIPT",
        "subtopic": "closures",
        "difficulty": "MEDIUM",
        "text": "What will this code log?",
        "codeSnippet": "for (var i = 0; i < 3; i++) {\n  setTimeout(() => console.log(i), 0);\n}",
        "options": ["0 1 2", "3 3 3", "undefined undefined undefined", "0 0 0"],
        "correctOptionIndex": 1,
        "explanation": "`var` is function-scoped, so by the time the timeouts run, `i` is 3."
      }
    ]
    

    这种格式刻意保持简单:类型明确、差异清晰,且无需讨论解析中的边缘情况。如果内容创建者将来希望使用Markdown或YAML格式编写,可以在预构建阶段将这些格式转换为JSON——核心脚本本身应保持简单且可预测。

    对于富文本而言,应将原始源字符串直接存储在数据库中——无论是Markdown、纯文本还是创建者熟悉的任何格式——然后在应用需要显示的地方进行渲染。如果在初始阶段就将其转换为HTML,就会使你绑定在某个特定的渲染库上,日后更换时还会带来迁移难题。应保持源数据的原始状态,仅在实际需要时再进行渲染。

    第3步:使用插入更新而非彻底清除数据

    在还没有真实用户时,用deleteMany清空表格后再通过createMany重新填充是可行的。一旦用户记录开始关联内容行,就应改用基于稳定标识符的插入更新操作。

    简化的Prisma模型:

    model Question {
      id                 String   @id @default(cuid())
      externalId         String   @unique
      topic              Topic
      subtopic           String
      difficulty         Difficulty
      text               String
      codeSnippet        String?
      options            String[]
      correctOptionIndex Int
      explanation        String
    }
    

    数据初始化脚本:

    import fs from 'node:fs/promises';
    import path from 'node:path';
    import { PrismaClient } from '@prisma/client';
    import { questionsFileSchema, type QuestionContent } from '@myapp/shared';
    
    const prisma = new PrismaClient();
    const CONTENT_DIR = path.resolve(__dirname, '../../../content/questions');
    
    async function loadQuestionsFromDisk(): Promise<QuestionContent[]> {
      const rows: QuestionContent[] = [];
      const topicDirs = await fs.readdir(CONTENT_DIR, { withFileTypes: true });
    
      for (const topicDir of topicDirs) {
        if (!topicDir.isDirectory()) continue;
    
        const dirPath = path.join(CONTENT_DIR, topicDir.name);
        const files = (await fs.readdir(dirPath)).filter((f) => f.endsWith('.json'));
    
        for (const file of files) {
          const raw = await fs.readFile(path.join(dirPath, file), 'utf8');
          const questions = questionsFileSchema.parse(JSON.parse(raw));
          rows.push(...questions);
        }
      }
    
      return rows;
    }
    
    async function main() {
      const questions = await loadQuestionsFromDisk();
      let created = 0;
      let updated = 0;
    
      for (const q of questions) {
        const data = {
          topic: q.topic,
          subtopic: q.subtopic,
          difficulty: q.difficulty,
          text: q.text,
          codeSnippet: q.codeSnippet ?? null,
          options: q.options,
          correctOptionIndex: q.correctOptionIndex,
          explanation: q.explanation,
        };
    
        const existing = await prisma.question.findUnique({
          where: { externalId: q.id },
        });
    
        if (existing) {
          await prisma.question.update({ where: { externalId: q.id }, data });
          updated++;
        } else {
          await prisma.question.create({ data: { externalId: q.id, ...data } });
          created++;
        }
      }
    
      console.log(`Seeded: ${created} created, ${updated} updated`);
    }
    
    main()
      .catch((err) => {
        console.error(err);
        process.exit(1);
      })
      .finally(() => prisma.$disconnect());
    

    此处有三个关键点:

    1. questionsFileSchema.parse(...)能确保格式错误的JSON完全不会进入数据库。
    2. findUnique({ where: { externalId: q.id } })是根据内容自身的ID进行匹配,而非数据库的内部主键。
    3. 选择更新而非删除可以保留用户与该记录关联的任何答案或进度信息。

    你可能会问,为何不直接使用 createMany({ skipDuplicates: true }) 呢?虽然该选项可以防止重复行,但当内容发生变化时它不会修改过时的文本。若要在部署时同步内容,显式编写插入或更新操作更为清晰,且能在各种数据库引擎中正常工作。只有在经过性能分析确认确实需要更高速度时,才考虑使用原始的 INSERT ... ON CONFLICT DO UPDATE 语句。

    第4步:在CI中快速失败

    验证内容并不一定需要数据库。对于任何修改 content/ 目录的拉取请求,都应执行以下操作:

    // scripts/validate-content.ts
    import fs from 'node:fs/promises';
    import { glob } from 'glob';
    import { questionsFileSchema } from '@myapp/shared';
    
    const files = await glob('content/**/*.json');
    let failed = 0;
    
    for (const file of files) {
      try {
        const raw = await fs.readFile(file, 'utf8');
        questionsFileSchema.parse(JSON.parse(raw));
        console.log(`✓ ${file}`);
      } catch (err) {
        console.error(`✗ ${file}`, err);
        failed++;
      }
    }
    
    process.exit(failed > 0 ? 1 : 0);
    
    # .github/workflows/validate-content.yml
    name: Validate content
    on:
      pull_request:
        paths: ['content/**']
    
    jobs:
      validate:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: pnpm/action-setup@v2
          - run: pnpm install
          - run: pnpm tsx scripts/validate-content.ts
    

    存在问题的内容会导致拉取请求失败——绝不会悄无声息地通过。

    部署流程如下:

    npx prisma migrate deploy
    npx prisma db seed
    

    内容更新就变成了普通的发布流程:合并、迁移、初始化数据。

    第5步:当架构发生变化时

    迟早,你的 Zod 模式会发生变化。也许你会添加一个 hint 字段,或者将 codeSnippet 改名为 code,又或者让 options 从字符串数组变为对象数组。

    无论发生何种变化,现有的 JSON 文件都会不再符合该模式,从而导致种子脚本出错。不必惊慌——处理内容模式的变更时,可以完全参照数据库迁移的方式操作。

    首先需要对模式本身进行版本控制:

    // schemas/question.v1.ts — old shape
    // schemas/question.v2.ts — new shape
    // schemas/question.ts   — export latest as `questionSchema`
    

    接着对磁盘上的文件执行一次性的代码修改操作:

    // scripts/codemod-questions-v2.ts
    import fs from 'node:fs/promises';
    import { glob } from 'glob';
    import { z } from 'zod';
    import { questionSchemaV1 } from '@myapp/shared/schemas/question.v1';
    
    const v1File = z.array(questionSchemaV1);
    
    for (const file of await glob('content/**/*.json')) {
      const old = v1File.parse(JSON.parse(await fs.readFile(file, 'utf8')));
    
      const next = old.map((q) => ({
        ...q,
        hint: null,
        code: q.codeSnippet,
        codeSnippet: undefined,
      }));
    
      await fs.writeFile(file, JSON.stringify(next, null, 2));
    }
    

    执行代码修改后,提交更新后的 JSON 文件,让种子脚本指向 v2 版的模式,最后进行部署。整个流程就是:代码修改、提交、更新、发布。

    这与你使用 prisma migrate 时遵循的规范相同,效果也是一样的。

    你能获得的好处

    内容审核变成了代码审查。“这个解释真的正确吗?”这样的疑问不再通过Slack消息表达,而是以带有清晰差异对比的拉取请求形式呈现。

    无需自行开发定制化代码检查工具,即可获得自动验证功能。Zod能够检测枚举值中的拼写错误、缺失的必填字段以及超出范围的索引——架构本身就起到了代码检查工具的作用。

    完全省去了CMS带来的繁琐流程。无需构建管理面板,无需单独处理编辑者认证,也无需维护第二个部署目标——尤其是当负责编写内容的人正是那些负责发布代码的工程师时。

    环境始终可复现。只需克隆仓库、运行迁移脚本、执行初始化命令,就能在每台机器上得到完全一致的问题库。

    批量操作会以脚本形式实现,而非手动点击完成。将40道题的标签从MEDIUM改为HARD只需一条sed命令或简短脚本,无需在管理界面进行40次单独编辑。

    用户进度会在部署后依然保留。由于使用了稳定编号并结合插入/更新功能,即便题目出现拼写错误,也不会断开用户已提交的答案与题目的关联。

    需要放弃的方面

    有必要提前说明其中的权衡:

    • 非工程师通常不会喜欢使用Git。如果需要非技术编辑参与,就必须提供CSV导入方式、内部工具,或是能输出JSON的无界面CMS。
    • 系统没有内置的草稿与已发布版本管理流程。只有位于main分支上的内容才会被正式使用。若需要草稿功能,就必须通过分支来实现。
  • 媒体资源不应放在JSON文件中。图片和视频应存储在对象存储中,并通过URL引用。
  • 当有多人同时编辑内容时,合并冲突会成为一个现实风险。可通过将内容拆分成按主题或难度分类的小文件,而非一个庞大的questions.json,来降低这一风险。
  • 在生产环境中对任何内容进行修改都需要部署。如果您的使用场景要求在不重新部署的情况下更新内容,那么这种方案并不适用。
  • 何时使用它

    当您的内容具有结构化且重复性高、工程团队规模较小、处于MVP与早期生产阶段之间、需要对内容更改进行追踪,且您已经在使用Prisma时,这种模式非常合适。

    当有非技术编辑每日进行内容发布、存在复杂的审批流程、过度依赖媒体资源,或需要直接在生产环境中编辑实时内容时,这种系统就无法很好地发挥作用。

    核心要点

    实际上所需的并非CMS,而是与代码遵循相同标准的内容管理方式:对内容进行版本控制、验证、审核,并通过已被信任的基础设施进行部署。

    Git中的JSON是真实数据来源。Zod负责数据校验。Prisma的seed步骤用于加载数据。稳定标识符则能在底层内容发生变化时保持用户数据的完整性。

    如果目前还没有用户,可以先从简单的清空重载方式开始。一旦进度数据变得重要,就改用插入或更新操作。在错误的枚举拼写传入测试环境之前,先加入CI验证环节。在出现第二个破坏性变更之前,就为内容架构做好版本控制。

    整个设计刻意避免华而不实——这正是其目的所在。把惊喜留给产品本身,而非负责存储问题的流程。

    相关阅读

  • 替代 TypeScript 的 any:六种适用于常见场景的类型安全模式 — 了解处理不可预测数据时,可替代 TypeScript any 的实用类型安全方案 — 包括未知类型处理、泛型、区分联合以及全面检查等。
  • 从 Prisma 迁移到 Drizzle:六个月回顾 — 一位开发者分享了将基于 PostgreSQL 的 TypeScript 开发栈从 Prisma 切换到 Drizzle ORM 的实际测试结果与权衡分析。
  • 无构建错误地将 NestJS 与 Prisma 7 部署到 Cloud Run 上 — 一个可用的 GitHub Actions 流水线,用于将基于 Bun、Prisma 7 和 Neon 的 NestJS 应用部署到 Cloud Run,同时解决了令团队头疼的 Docker 及连接问题。