NextJS14 app + Trpc + PayloadCMS + MongoDB 自定义服务器搭建

15 阅读7分钟

自定义服务器启动

相关依赖

  • dotenv 读取 env 文件数据
  • express node 框架
基础示例如下
// src/server/index.ts
import 'dotenv/config';
import express from 'express';
import chalk from 'chalk';

const port = Number(process.env.PORT) || 3000;
const app = express();
const nextApp = next({
  dev: process.env.NODE_ENV !== 'production',
  port: PORT,
});

const nextHandler = nextApp.getRequestHandler();
const start = async () => {
  // 准备生成 .next 文件
  nextApp.prepare().then(() => {
    app.all('*', (req, res) => {
      return nextHandler(req, res);
    });

    app.listen(port, () => {
      console.log(
        '\x1b[36m%s\x1b[0m',
        `🎉🎉> Ready on http://localhost:${port}`
      );
    });
  });
};

start();
// package.json
// ...
// 这里需要使用 esno 而不能使用 node. 因为 node 是 CommonJs 而我们代码中使用 es 规范
"dev": "esno src/server/index.ts"
// ...

配置 payload cms

个人理解 payload 和 cms 是两个东西,只是使用 payload 时自动使用了 cms, 如果不使用 cms 的话就不管。 payload 主要是操作数据库数据的,也有一些集成

相关依赖

  • @payloadcms/bundler-webpack
  • @payloadcms/db-mongodb
  • @payloadcms/richtext-slate
  • payload

开始前先抽离 nextApp nextHandler 函数,server 文件夹新建 next-utils.ts

import next from 'next';

const PORT = Number(process.env.PORT) || 3000;

// 创建 Next.js 应用实例
export const nextApp = next({
  dev: process.env.NODE_ENV !== 'production',
  port: PORT,
});

// 获取 Next.js 请求处理器。用于处理传入的 HTTP 请求,并根据 Next.js 应用的路由来响应这些请求。
export const nextRequestHandler = nextApp.getRequestHandler();
  1. 配置 config. 在 server 文件夹下创建 payload.config.ts
基础示例如下
/**
 * 配置 payload CMS 无头内容管理系统
 * @author peng-xiao-shuai
 * @see https://www.youtube.com/watch?v=06g6YJ6JCJU&t=8070s
 */

import path from 'path';
import { postgresAdapter } from '@payloadcms/db-postgres';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import { webpackBundler } from '@payloadcms/bundler-webpack';
import { slateEditor } from '@payloadcms/richtext-slate';
import { buildConfig } from 'payload/config';

export default buildConfig({
  // 设置服务器的 URL,从环境变量 NEXT_PUBLIC_SERVER_URL 获取。
  serverURL: process.env.NEXT_PUBLIC_SERVER_URL || '',
  admin: {
    // 设置用于 Payload CMS 管理界面的打包工具,这里使用了
    bundler: webpackBundler(),
    // 配置管理系统 Meta
    meta: {
      titleSuffix: 'Payload manage',
    },
  },
  // 定义路由,例如管理界面的路由。
  routes: {
    admin: '/admin',
  },
  // 设置富文本编辑器,这里使用了 Slate 编辑器。
  editor: slateEditor({}),
  typescript: {
    outputFile: path.resolve(__dirname, 'payload-types.ts'),
  },
  // 配置请求的速率限制,这里设置了最大值。
  rateLimit: {
    max: 2000,
  },

  // 下面 db 二选一。提示:如果是用 mongodb 没有问题,使用 postgres 时存在问题,请更新依赖包
  db: mongooseAdapter({
    url: process.env.DATABASE_URI!,
  }),

  db: postgresAdapter({
    pool: {
      connectionString: process.env.SUPABASE_URL,
    },
  }),
});
  1. 初始化 payload.init. 这里初始化的时候还做了缓存机制. 在 server 文件夹下创建 get-payload.ts
基础示例如下
/**
 * 处理缓存机制。确保应用中多处需要使用 Payload 客户端时不会重复初始化,提高效率。
 * @author peng-xiao-shuai
 */
import type { InitOptions } from 'payload/config';
import type { Payload } from 'payload';
import payload from 'payload';

// 使用 Node.js 的 global 对象来存储缓存。
let cached = (global as any).payload;

if (!cached) {
  cached = (global as any).payload = {
    client: null,
    promise: null,
  };
}

/**
 * 负责初始化 Payload 客户端
 * @return {Promise<Payload>}
 */
export const getPayloadClient = async ({
  initOptions,
}: {
  initOptions: Partial<InitOptions>;
}): Promise<Payload> => {
  if (!process.env.PAYLOAD_SECRET) {
    throw new Error('PAYLOAD_SECRET is missing');
  }

  if (cached.client) {
    return cached.client;
  }

  if (!cached.promise) {
    // payload 初始化赋值
    cached.promise = payload.init({
      // email: {
      //   transport: transporter,
      //   fromAddress: 'hello@joshtriedcoding.com',
      //   fromName: 'DigitalHippo',
      // },
      secret: process.env.PAYLOAD_SECRET,
      local: initOptions?.express ? false : true,
      ...(initOptions || {}),
    });
  }

  try {
    cached.client = await cached.promise;
  } catch (e: unknown) {
    cached.promise = null;
    throw e;
  }

  return cached.client;
};
  1. index.ts 引入
基础示例如下
// 读取环境变量
import 'dotenv/config';
import express from 'express';
import { nextApp, nextRequestHandler } from './next-utils';
import { getPayloadClient } from './get-payload';

const port = Number(process.env.PORT) || 3000;
const app = express();
const start = async () => {
  // 获取 payload
  const payload = await getPayloadClient({
    initOptions: {
      express: app,
      onInit: async (cms) => {
        console.log('\x1b[36m%s\x1b[0m', '✨✨Admin URL: ' + cms.getAdminURL());
      },
    },
  });

  app.use((req, res) => nextRequestHandler(req, res));

  // 准备生成 .next 文件
  nextApp.prepare().then(() => {
    app.listen(port, () => {
      console.log(
        '\x1b[36m%s\x1b[0m',
        `🎉🎉> Ready on http://localhost:${port}`
      );
    });
  });
};

start();
  1. dev 运行配置. 安装 cross-env nodemon. 设置 payload 配置文件路径. nodemon 启动
// package.json
// ...
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/server/payload.config.ts nodemon",
// ...
  1. nodemon 配置。根目录创建 nodemon.json
{
  "watch": ["src/server/index.ts"],
  "exec": "ts-node --project tsconfig.server.json src/server/index.ts -- -I",
  "ext": "js ts",
  "stdin": false
}

payload 进阶

  1. 定义类型。payload.config.ts 同级目录新增 payload-types.ts
示例如下
// payload.config.ts
// ...
typescript: {
  outputFile: path.resolve(__dirname, 'payload-types.ts'),
}
// ...
// package.json 新增命令
// ...
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/server/payload.config.ts payload generate:types",
// ...

执行 yarn generate:types 那么会在 payload-types.ts 文件中写入基础集合(Collection)类型

  1. 修改用户 Collection 集合。collection

前提 server 文件夹下新增 collections 文件夹然后新增 Users.ts 文件

示例如下
// Users.ts
import { CollectionConfig } from 'payload/types';
export const Users: CollectionConfig = {
  slug: 'users',
  auth: true,
  fields: [
    {
      // 定义地址
      name: 'address',
      required: true,
      type: 'text', // 贴别注意不同的类型有不同的数据 https://payloadcms.com/docs/fields/text
    },
    {
      name: 'points',
      hidden: true,
      defaultValue: 0,
      type: 'number',
    },
  ],
  access: {
    read: () => true,
    delete: () => false,
    create: ({ data, id, req }) => {
      // 设置管理系统不能添加
      return !req.headers.referer?.includes('/admin');
    },
    update: ({ data, id, req }) => {
      // 设置管理系统不能添加
      return !req.headers.referer?.includes('/admin');
    },
  },
};

还需要更改 payload.config.ts 中配置

import { Users } from './collections/Users';
// ...
collections: [Users],
admin: {
  user: 'users', // @see https://payloadcms.com/docs/admin/overview#the-admin-user-collection
  //  ...
},
// ...
  1. 新增在创建一个积分记录集合。collections 文件夹下新增 PointsRecord.ts 文件
/**
 * 积分记录
 */
import { CollectionBeforeChangeHook, CollectionConfig } from 'payload/types';
import { PointsRecord as PointsRecordType } from '../payload-types';
import { getPayloadClient } from '../get-payload';

// @see https://payloadcms.com/docs/hooks/collections#beforechange
// https://payloadcms.com/docs/hooks/collections 中包含所有集合钩子
const beforeChange: CollectionBeforeChangeHook<PointsRecordType> = async ({
  data,
  operate // 操作类型,这里就不需要判断了,因为只有修改前才会触发这个钩子,而修改又只有 update create delete 会触发。update delete 又被我们禁用了所以只有 create 会触发
}) => {
  // 获取 payload
  const payload = await getPayloadClient();

  // 修改数据
  data.operateType = (data.count || 0) >= 0 ? 'added' : 'reduce';

  // 获取当前用户ID的数据
  const result = await payload.findByID({
    collection: 'users', // required
    id: data.userId as number, // required
  });

  // 修改用户数据
  await payload.update({
    collection: 'users', // required
    id: data.userId as number, // required
    data: {
      ...result,
      points: (result.points || 0) + data.count!,
    },
  });

  return data;
};

export const PointsRecord: CollectionConfig = {
  slug: 'points-record', // 集合名称,也就是数据库表名
  fields: [
    {
      name: 'userId',
      type: 'relationship',
      required: true,
      relationTo: 'users',
    },
    {
      name: 'count',
      type: 'number',
      required: true,
    },
    {
      name: 'operateType',
      type: 'select',
      // 这里隐藏避免在 cms 中显示,因为 operateType 值是由判断 count 生成。
      hidden: true,
      options: [
        {
          label: '增加',
          value: 'added',
        },
        {
          label: '减少',
          value: 'reduce',
        },
      ],
    },
  ],
  // 这个集合操作数据前的钩子
  hooks: {
    beforeChange: [beforeChange],
  },
  access: {
    read: () => true,
    create: () => true,
    update: () => false,
    delete: () => false,
  },
};

同样还需要更改 payload.config.ts 中配置

import { Users } from './collections/Users';
import { PointsRecord } from './collections/PointsRecord';
// ...
collections: [UsersPointsRecord],
// ...

安装 trpc

相关依赖

  • @trpc/server
  • @trpc/client
  • @trpc/next
  • @trpc/react-query
  • @tanstack/react-query
  • zod 校验

& 是在 next.config.js 文件夹中进行了配置

import path from 'path';
/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
    // 设置别名
    config.resolve.alias['@'] = path.join(__dirname, 'src');
    config.resolve.alias['&'] = path.join(__dirname, 'src/server');

    // 重要: 返回修改后的配置
    return config;
  },
};

module.exports = nextConfig;
  1. server 文件夹下面创建 trpc 文件夹然后创建 trpc.ts 文件。初始化 trpc
基础示例如下
import { initTRPC } from '@trpc/server';
import { ExpressContext } from '../';

// context 创建上下文
const t = initTRPC.context<ExpressContext>().create();

// Base router and procedure helpers
export const router = t.router;
export const procedure = t.procedure;
  1. 同级目录新建 client.ts 文件 trpc
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from './routers';

export const trpc = createTRPCReact < AppRouter > {};
  1. app 文件夹下新增 components 文件夹在创建 Providers.tsx 文件为客户端组件
基础示例如下
'use client';

import { PropsWithChildren, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { trpc } from '&/trpc/client';
import { httpBatchLink } from '@trpc/client';

export const Providers = ({ children }: PropsWithChildren) => {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: `${process.env.NEXT_PUBLIC_SERVER_URL}/api/trpc`,

          /**
           * @see https://trpc.io/docs/client/headers
           */
          // async headers() {
          //   return {
          //     authorization: getAuthCookie(),
          //   };
          // },

          /**
           * @see https://trpc.io/docs/client/cors
           */
          fetch(url, options) {
            return fetch(url, {
              ...options,
              credentials: 'include',
            });
          },
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
};
  1. server/trpc 文件夹下创建 routers.ts 文件 example
基础示例如下
import { procedure, router } from './trpc';
export const appRouter = router({
  hello: procedure
    .input(
      z
        .object({
          text: z.string().nullish(),
        })
        .nullish()
    )
    .query((opts) => {
      return {
        greeting: `hello ${opts.input?.text ?? 'world'}`,
      };
    }),
});
// export type definition of API
export type AppRouter = typeof appRouter;
  1. 任意 page.tsx 页面 example
基础示例如下
// 'use client'; // 如果页面有交互的话需要改成客户端组件
import { trpc } from '&/trpc/client';

export function MyComponent() {
  // input is optional, so we don't have to pass second argument
  const helloNoArgs = trpc.hello.useQuery();
  const helloWithArgs = trpc.hello.useQuery({ text: 'client' });

  return (
    <div>
      <h1>Hello World Example</h1>
      <ul>
        <li>
          helloNoArgs ({helloNoArgs.status}):{' '}
          <pre>{JSON.stringify(helloNoArgs.data, null, 2)}</pre>
        </li>
        <li>
          helloWithArgs ({helloWithArgs.status}):{' '}
          <pre>{JSON.stringify(helloWithArgs.data, null, 2)}</pre>
        </li>
      </ul>
    </div>
  );
}
  1. index.ts 文件引入
基础示例如下
import express from 'express';
import { nextApp, nextRequestHandler } from './next-utils';
import { getPayloadClient } from './get-payload';
import * as trpcExpress from '@trpc/server/adapters/express';
import { inferAsyncReturnType } from '@trpc/server';
import { config } from 'dotenv';
import { appRouter } from './trpc/routers';
config({ path: '.env.local' });
config({ path: '.env' });

const port = Number(process.env.PORT) || 3000;
const app = express();

const createContext = ({
  req,
  res,
}: trpcExpress.CreateExpressContextOptions) => ({ req, res });

export type ExpressContext = inferAsyncReturnType<typeof createContext>;

const start = async () => {
  // 获取 payload
  const payload = await getPayloadClient({
    initOptions: {
      express: app,
      onInit: async (cms) => {
        console.log('\x1b[36m%s\x1b[0m', '✨✨Admin URL: ' + cms.getAdminURL());
      },
    },
  });

  app.use(
    '/api/trpc',
    trpcExpress.createExpressMiddleware({
      router: appRouter,
      /**
       * @see https://trpc.io/docs/server/adapters/express#3-use-the-express-adapter
       * @example
        // 加了 返回了 req, res 之后可以在 trpc 路由中直接访问
        import { createRouter } from '@trpc/server';
        import { z } from 'zod';

        const exampleRouter = createRouter<Context>()
          .query('exampleQuery', {
            input: z.string(),
            resolve({ input, ctx }) {
              // 直接访问 req 和 res
              const userAgent = ctx.req.headers['user-agent'];
              ctx.res.status(200).json({ message: 'Hello ' + input });

              // 你的业务逻辑
              ...
            },
          });
       */
      createContext,
    })
  );
  app.use((req, res) => nextRequestHandler(req, res));

  // 准备生成 .next 文件
  nextApp.prepare().then(() => {
    app.listen(port, () => {
      console.log(
        '\x1b[36m%s\x1b[0m',
        `🎉🎉> Ready on http://localhost:${port}`
      );
    });
  });
};

start();

报错信息

sharp module

ERROR (payload): Error: cannot connect to MongoDB. Details: queryTxt ETIMEOUT xxx.mongodb.net

  • 设置网络 Ipv4 DNS 服务器为 114.114.114.144
  • 关闭防火墙
  • 设置 mongodb 可访问的 ip0.0.0.0/0

服务端

自定义服务器启动

相关依赖

  • dotenv 读取 env 文件数据
  • express node 框架
基础示例如下
// src/server/index.ts
import 'dotenv/config';
import express from 'express';
import chalk from 'chalk';

const port = Number(process.env.PORT) || 3000;
const app = express();
const nextApp = next({
  dev: process.env.NODE_ENV !== 'production',
  port: PORT,
});

const nextHandler = nextApp.getRequestHandler();
const start = async () => {
  // 准备生成 .next 文件
  nextApp.prepare().then(() => {
    app.all('*', (req, res) => {
      return nextHandler(req, res);
    });

    app.listen(port, () => {
      console.log(
        '\x1b[36m%s\x1b[0m',
        `🎉🎉> Ready on http://localhost:${port}`
      );
    });
  });
};

start();
// package.json
// ...
// 这里需要使用 esno 而不能使用 node. 因为 node 是 CommonJs 而我们代码中使用 es 规范
"dev": "esno src/server/index.ts"
// ...

配置 payload cms

个人理解 payload 和 cms 是两个东西,只是使用 payload 时自动使用了 cms, 如果不使用 cms 的话就不管。 payload 主要是操作数据库数据的,也有一些集成

相关依赖

  • @payloadcms/bundler-webpack
  • @payloadcms/db-mongodb
  • @payloadcms/richtext-slate
  • payload

开始前先抽离 nextApp nextHandler 函数,server 文件夹新建 next-utils.ts

import next from 'next';

const PORT = Number(process.env.PORT) || 3000;

// 创建 Next.js 应用实例
export const nextApp = next({
  dev: process.env.NODE_ENV !== 'production',
  port: PORT,
});

// 获取 Next.js 请求处理器。用于处理传入的 HTTP 请求,并根据 Next.js 应用的路由来响应这些请求。
export const nextRequestHandler = nextApp.getRequestHandler();
  1. 配置 config. 在 server 文件夹下创建 payload.config.ts
基础示例如下
/**
 * 配置 payload CMS 无头内容管理系统
 * @author peng-xiao-shuai
 * @see https://www.youtube.com/watch?v=06g6YJ6JCJU&t=8070s
 */

import path from 'path';
import { postgresAdapter } from '@payloadcms/db-postgres';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import { webpackBundler } from '@payloadcms/bundler-webpack';
import { slateEditor } from '@payloadcms/richtext-slate';
import { buildConfig } from 'payload/config';

export default buildConfig({
  // 设置服务器的 URL,从环境变量 NEXT_PUBLIC_SERVER_URL 获取。
  serverURL: process.env.NEXT_PUBLIC_SERVER_URL || '',
  admin: {
    // 设置用于 Payload CMS 管理界面的打包工具,这里使用了
    bundler: webpackBundler(),
    // 配置管理系统 Meta
    meta: {
      titleSuffix: 'Payload manage',
    },
  },
  // 定义路由,例如管理界面的路由。
  routes: {
    admin: '/admin',
  },
  // 设置富文本编辑器,这里使用了 Slate 编辑器。
  editor: slateEditor({}),
  typescript: {
    outputFile: path.resolve(__dirname, 'payload-types.ts'),
  },
  // 配置请求的速率限制,这里设置了最大值。
  rateLimit: {
    max: 2000,
  },

  // 下面 db 二选一。提示:如果是用 mongodb 没有问题,使用 postgres 时存在问题,请更新依赖包
  db: mongooseAdapter({
    url: process.env.DATABASE_URI!,
  }),

  db: postgresAdapter({
    pool: {
      connectionString: process.env.SUPABASE_URL,
    },
  }),
});
  1. 初始化 payload.init. 这里初始化的时候还做了缓存机制. 在 server 文件夹下创建 get-payload.ts
基础示例如下
/**
 * 处理缓存机制。确保应用中多处需要使用 Payload 客户端时不会重复初始化,提高效率。
 * @author peng-xiao-shuai
 */
import type { InitOptions } from 'payload/config';
import type { Payload } from 'payload';
import payload from 'payload';

// 使用 Node.js 的 global 对象来存储缓存。
let cached = (global as any).payload;

if (!cached) {
  cached = (global as any).payload = {
    client: null,
    promise: null,
  };
}

/**
 * 负责初始化 Payload 客户端
 * @return {Promise<Payload>}
 */
export const getPayloadClient = async ({
  initOptions,
}: {
  initOptions: Partial<InitOptions>;
}): Promise<Payload> => {
  if (!process.env.PAYLOAD_SECRET) {
    throw new Error('PAYLOAD_SECRET is missing');
  }

  if (cached.client) {
    return cached.client;
  }

  if (!cached.promise) {
    // payload 初始化赋值
    cached.promise = payload.init({
      // email: {
      //   transport: transporter,
      //   fromAddress: 'hello@joshtriedcoding.com',
      //   fromName: 'DigitalHippo',
      // },
      secret: process.env.PAYLOAD_SECRET,
      local: initOptions?.express ? false : true,
      ...(initOptions || {}),
    });
  }

  try {
    cached.client = await cached.promise;
  } catch (e: unknown) {
    cached.promise = null;
    throw e;
  }

  return cached.client;
};
  1. index.ts 引入
基础示例如下
// 读取环境变量
import 'dotenv/config';
import express from 'express';
import { nextApp, nextRequestHandler } from './next-utils';
import { getPayloadClient } from './get-payload';

const port = Number(process.env.PORT) || 3000;
const app = express();
const start = async () => {
  // 获取 payload
  const payload = await getPayloadClient({
    initOptions: {
      express: app,
      onInit: async (cms) => {
        console.log('\x1b[36m%s\x1b[0m', '✨✨Admin URL: ' + cms.getAdminURL());
      },
    },
  });

  app.use((req, res) => nextRequestHandler(req, res));

  // 准备生成 .next 文件
  nextApp.prepare().then(() => {
    app.listen(port, () => {
      console.log(
        '\x1b[36m%s\x1b[0m',
        `🎉🎉> Ready on http://localhost:${port}`
      );
    });
  });
};

start();
  1. dev 运行配置. 安装 cross-env nodemon. 设置 payload 配置文件路径. nodemon 启动
// package.json
// ...
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/server/payload.config.ts nodemon",
// ...
  1. nodemon 配置。根目录创建 nodemon.json
{
  "watch": ["src/server/index.ts"],
  "exec": "ts-node --project tsconfig.server.json src/server/index.ts -- -I",
  "ext": "js ts",
  "stdin": false
}

payload 进阶

  1. 定义类型。payload.config.ts 同级目录新增 payload-types.ts
示例如下
// payload.config.ts
// ...
typescript: {
  outputFile: path.resolve(__dirname, 'payload-types.ts'),
}
// ...
// package.json 新增命令
// ...
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/server/payload.config.ts payload generate:types",
// ...

执行 yarn generate:types 那么会在 payload-types.ts 文件中写入基础集合(Collection)类型

  1. 修改用户 Collection 集合。collection

前提 server 文件夹下新增 collections 文件夹然后新增 Users.ts 文件

示例如下
// Users.ts
import { CollectionConfig } from 'payload/types';
export const Users: CollectionConfig = {
  slug: 'users',
  auth: true,
  fields: [
    {
      // 定义地址
      name: 'address',
      required: true,
      type: 'text', // 贴别注意不同的类型有不同的数据 https://payloadcms.com/docs/fields/text
    },
    {
      name: 'points',
      hidden: true,
      defaultValue: 0,
      type: 'number',
    },
  ],
  access: {
    read: () => true,
    delete: () => false,
    create: ({ data, id, req }) => {
      // 设置管理系统不能添加
      return !req.headers.referer?.includes('/admin');
    },
    update: ({ data, id, req }) => {
      // 设置管理系统不能添加
      return !req.headers.referer?.includes('/admin');
    },
  },
};

还需要更改 payload.config.ts 中配置

import { Users } from './collections/Users';
// ...
collections: [Users],
admin: {
  user: 'users', // @see https://payloadcms.com/docs/admin/overview#the-admin-user-collection
  //  ...
},
// ...
  1. 新增在创建一个积分记录集合。collections 文件夹下新增 PointsRecord.ts 文件
/**
 * 积分记录
 */
import { CollectionBeforeChangeHook, CollectionConfig } from 'payload/types';
import { PointsRecord as PointsRecordType } from '../payload-types';
import { getPayloadClient } from '../get-payload';

// @see https://payloadcms.com/docs/hooks/collections#beforechange
// https://payloadcms.com/docs/hooks/collections 中包含所有集合钩子
const beforeChange: CollectionBeforeChangeHook<PointsRecordType> = async ({
  data,
  operate // 操作类型,这里就不需要判断了,因为只有修改前才会触发这个钩子,而修改又只有 update create delete 会触发。update delete 又被我们禁用了所以只有 create 会触发
}) => {
  // 获取 payload
  const payload = await getPayloadClient();

  // 修改数据
  data.operateType = (data.count || 0) >= 0 ? 'added' : 'reduce';

  // 获取当前用户ID的数据
  const result = await payload.findByID({
    collection: 'users', // required
    id: data.userId as number, // required
  });

  // 修改用户数据
  await payload.update({
    collection: 'users', // required
    id: data.userId as number, // required
    data: {
      ...result,
      points: (result.points || 0) + data.count!,
    },
  });

  return data;
};

export const PointsRecord: CollectionConfig = {
  slug: 'points-record', // 集合名称,也就是数据库表名
  fields: [
    {
      name: 'userId',
      type: 'relationship',
      required: true,
      relationTo: 'users',
    },
    {
      name: 'count',
      type: 'number',
      required: true,
    },
    {
      name: 'operateType',
      type: 'select',
      // 这里隐藏避免在 cms 中显示,因为 operateType 值是由判断 count 生成。
      hidden: true,
      options: [
        {
          label: '增加',
          value: 'added',
        },
        {
          label: '减少',
          value: 'reduce',
        },
      ],
    },
  ],
  // 这个集合操作数据前的钩子
  hooks: {
    beforeChange: [beforeChange],
  },
  access: {
    read: () => true,
    create: () => true,
    update: () => false,
    delete: () => false,
  },
};

同样还需要更改 payload.config.ts 中配置

import { Users } from './collections/Users';
import { PointsRecord } from './collections/PointsRecord';
// ...
collections: [UsersPointsRecord],
// ...

安装 trpc

相关依赖

  • @trpc/server
  • @trpc/client
  • @trpc/next
  • @trpc/react-query
  • @tanstack/react-query
  • zod 校验

& 是在 next.config.js 文件夹中进行了配置

import path from 'path';
/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
    // 设置别名
    config.resolve.alias['@'] = path.join(__dirname, 'src');
    config.resolve.alias['&'] = path.join(__dirname, 'src/server');

    // 重要: 返回修改后的配置
    return config;
  },
};

module.exports = nextConfig;
  1. server 文件夹下面创建 trpc 文件夹然后创建 trpc.ts 文件。初始化 trpc
基础示例如下
import { initTRPC } from '@trpc/server';
import { ExpressContext } from '../';

// context 创建上下文
const t = initTRPC.context<ExpressContext>().create();

// Base router and procedure helpers
export const router = t.router;
export const procedure = t.procedure;
  1. 同级目录新建 client.ts 文件 trpc
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from './routers';

export const trpc = createTRPCReact < AppRouter > {};
  1. app 文件夹下新增 components 文件夹在创建 Providers.tsx 文件为客户端组件
基础示例如下
'use client';

import { PropsWithChildren, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { trpc } from '&/trpc/client';
import { httpBatchLink } from '@trpc/client';

export const Providers = ({ children }: PropsWithChildren) => {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: `${process.env.NEXT_PUBLIC_SERVER_URL}/api/trpc`,

          /**
           * @see https://trpc.io/docs/client/headers
           */
          // async headers() {
          //   return {
          //     authorization: getAuthCookie(),
          //   };
          // },

          /**
           * @see https://trpc.io/docs/client/cors
           */
          fetch(url, options) {
            return fetch(url, {
              ...options,
              credentials: 'include',
            });
          },
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
};
  1. server/trpc 文件夹下创建 routers.ts 文件 example
基础示例如下
import { procedure, router } from './trpc';
export const appRouter = router({
  hello: procedure
    .input(
      z
        .object({
          text: z.string().nullish(),
        })
        .nullish()
    )
    .query((opts) => {
      return {
        greeting: `hello ${opts.input?.text ?? 'world'}`,
      };
    }),
});
// export type definition of API
export type AppRouter = typeof appRouter;
  1. 任意 page.tsx 页面 example
基础示例如下
// 'use client'; // 如果页面有交互的话需要改成客户端组件
import { trpc } from '&/trpc/client';

export function MyComponent() {
  // input is optional, so we don't have to pass second argument
  const helloNoArgs = trpc.hello.useQuery();
  const helloWithArgs = trpc.hello.useQuery({ text: 'client' });

  return (
    <div>
      <h1>Hello World Example</h1>
      <ul>
        <li>
          helloNoArgs ({helloNoArgs.status}):{' '}
          <pre>{JSON.stringify(helloNoArgs.data, null, 2)}</pre>
        </li>
        <li>
          helloWithArgs ({helloWithArgs.status}):{' '}
          <pre>{JSON.stringify(helloWithArgs.data, null, 2)}</pre>
        </li>
      </ul>
    </div>
  );
}
  1. index.ts 文件引入
基础示例如下
import express from 'express';
import { nextApp, nextRequestHandler } from './next-utils';
import { getPayloadClient } from './get-payload';
import * as trpcExpress from '@trpc/server/adapters/express';
import { inferAsyncReturnType } from '@trpc/server';
import { config } from 'dotenv';
import { appRouter } from './trpc/routers';
config({ path: '.env.local' });
config({ path: '.env' });

const port = Number(process.env.PORT) || 3000;
const app = express();

const createContext = ({
  req,
  res,
}: trpcExpress.CreateExpressContextOptions) => ({ req, res });

export type ExpressContext = inferAsyncReturnType<typeof createContext>;

const start = async () => {
  // 获取 payload
  const payload = await getPayloadClient({
    initOptions: {
      express: app,
      onInit: async (cms) => {
        console.log('\x1b[36m%s\x1b[0m', '✨✨Admin URL: ' + cms.getAdminURL());
      },
    },
  });

  app.use(
    '/api/trpc',
    trpcExpress.createExpressMiddleware({
      router: appRouter,
      /**
       * @see https://trpc.io/docs/server/adapters/express#3-use-the-express-adapter
       * @example
        // 加了 返回了 req, res 之后可以在 trpc 路由中直接访问
        import { createRouter } from '@trpc/server';
        import { z } from 'zod';

        const exampleRouter = createRouter<Context>()
          .query('exampleQuery', {
            input: z.string(),
            resolve({ input, ctx }) {
              // 直接访问 req 和 res
              const userAgent = ctx.req.headers['user-agent'];
              ctx.res.status(200).json({ message: 'Hello ' + input });

              // 你的业务逻辑
              ...
            },
          });
       */
      createContext,
    })
  );
  app.use((req, res) => nextRequestHandler(req, res));

  // 准备生成 .next 文件
  nextApp.prepare().then(() => {
    app.listen(port, () => {
      console.log(
        '\x1b[36m%s\x1b[0m',
        `🎉🎉> Ready on http://localhost:${port}`
      );
    });
  });
};

start();

报错信息

sharp module

ERROR (payload): Error: cannot connect to MongoDB. Details: queryTxt ETIMEOUT xxx.mongodb.net

  • 设置网络 Ipv4 DNS 服务器为 114.114.114.144
  • 关闭防火墙
  • 设置 mongodb 可访问的 ip0.0.0.0/0 image.png
  • 在引入 trpc 的页面,需要将页面改成客户端组件

TypeError: (0 , react**WEBPACK\_IMPORTED\_MODULE\_3**.createContext) is not a function

  • 在引入 trpc 的页面,需要将页面改成客户端组件

重启服务端

  • server 文件夹下面只有 index.ts 文件会被保存会重新加载服务端,其他文件更改需要再去 index.ts 重新保存

或者将 nodemon.json 配置文件更改。watch 中添加其他的文件,保存后自动重启

{
  "watch": ["src/server/*.ts", "src/server/**/*.ts"],
  "exec": "ts-node --project tsconfig.server.json src/server/index.ts -- -I",
  "ext": "js ts",
  "stdin": false
}

示例仓库地址:Github

联系邮箱:1612565136@qq.com

环境变量

克隆后根目录新建 .env.local,写入相应环境变量

# 数据库连接地址
DATABASE_URL

# 邮件 API_KEY 需要去 https://resend.com/ 申请
RESEND_API_KEY

# 邮件 PUSHER_APP_ID NEXT_PUBLIC_PUSHER_APP_KEY PUSHER_APP_SECRET NEXT_PUBLIC_PUSHER_APP_CLUSTER 需要去 https://pusher.com/ 申请
PUSHER_APP_ID
NEXT_PUBLIC_PUSHER_APP_KEY
PUSHER_APP_SECRET
NEXT_PUBLIC_PUSHER_APP_CLUSTER