DHTMLX Scheduler: 与 Google 日历的双 向同步(Node.js)
在本指南中,你将实现 DHTMLX Scheduler 与 Google 日历 之间的 双向同步,在一个小型 Node.js 应用中:
- 从 Google 日历加载日历和事件到 Scheduler
- 将 Scheduler 的创建/更新/删除操作推送回 Google 日历
此方法实现的是通过 API 调用的 双向同步(Scheduler → 后端 → Google 日历)。它不实现 Google → Scheduler 的实时推送更新(webhook)。如果你直接在 Google 日历中修改事件,请重新加载应用(或重新加载一个日期范围)以在 Scheduler 中看到更新的数据。
你将构建:
- 一个包含 Google OAuth 2.0 的 Node.js + Express 后端(Passport)以及一个用于 Scheduler 的小型 REST API
- 一个事件映射层(Google ↔ Scheduler),包括基本的计划内事件/例外处理
- 一个通过
scheduler.createDataProcessor()连接到后端的 Scheduler 客户端
完整的源代码可在 GitHub 上获取:https://github.com/DHTMLX/scheduler-google-calendar-demo。
前提条件
- Node.js 18+
- 拥有 Google Cloud Console 访问权限的 Google 账号
- 对 TypeScript 和 Express 的基本熟悉
- 访问 DHTMLX Scheduler 包(示例使用
@dhx/trial-scheduler)
演示仓库
与本指南匹配的完整可运行项目可在 GitHub 上获取:
该指南解释了关键步骤并展示了重要的集成代码。仓库是“完整可运行的参考实现”。
项目设置
在本节中,你将准备 Google OAuth 凭据、配置项目并在本地运行应用。
1) 获取项目代码
执行以下任意一种操作:
- 克隆仓库:
git clone https://github.com/dhtmlx/scheduler-google-auth-demo.git
cd scheduler-google-auth-demo
如果你的项目需要从私有注册表安装 @dhx/* 包,请配置 npm:
npm config set @dhx:registry https://npm.dhtmlx.com
2) 配置 Google Cloud(OAuth 2.0)
在此步骤中,你将创建后端用于以用户身份访问 Google 日历的 OAuth 凭据。
本指南在 Testing 模式下使用 OAuth(开发推荐)。在该模式下,只有被列为测试用户的用户才可以授权应用。
2.1 创建或选择 Google Cloud 项目
- 打开 Google Cloud Console。
- 选择一个已有项目或创建一个新项目。
2.2 启用 Google 日历 API
- 转到 APIs & Services → Library。
- 搜索 Google Calendar API。
- 点击 Enable(启用)。
2.3 配置 OAuth 同意屏幕
- 转到 APIs & Services → OAuth consent screen。
- 选择 External(对于消费型 Google 账户常见),然后点击 Create。
- 填写必填字段:
- App name(应用名称)
- User support email(用户支持邮箱)
- Developer contact email(开发者联系邮箱)
- 将 Publishing status 设置为 Testing。
- 添加 Test users:
- 添加你在开发/测试时将用于登录的 Google 账号。
如果在 Testing 模式下跳过测试用户,Google 将阻止未明确添加的账户授权。
2.4 创建 OAuth 客户端凭据
- 转到 APIs & Services → Credentials。
- 点击 Create credentials → OAuth client ID。
- 应用类型:Web application。
- 添加以下 Authorized JavaScript origin:
http://localhost:3000
- 添加以下 Authorized redirect URI:
http://localhost:3000/auth/google/callback
- 保存并复制:
- Client ID
- Client Secret
2.5 本集成使用的作用域
后端通过以下方式请求 Google 日历访问权限:
https://www.googleapis.com/auth/calendar
此作用域足以列出日历并执行事件的 CRUD 操作。
3) 配置环境变量
在此步骤中,你将向后端提供 OAuth 凭据和会话设置。
把 .env.example 复制为 .env,然后填写值:
GOOGLE_CLIENT_ID=<上一步中的 Client ID>
GOOGLE_CLIENT_SECRET=<上一步中的 Client Secret>
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback
SESSION_SECRET=some-long-random-string
PORT=3000
4) 安装依赖并运行
npm install
npm run start
打开:
http://localhost:3000
此时你应该能够点击 Add Google Calendars、完成登录,并在 Scheduler 中看到加载的日历与事件。
实现
本节后续内容将解释如何把集成组合在一起。如果你是在将本指南应用于现有应用,请将下面每个小节视为一个实现里程碑。
步骤 1 - 职责分离(后端 vs 客户端)
在此步骤中, 你将分离职责,让 Scheduler 保持为 UI 组件,后端负责 OAuth 和 Google API 调用。
一个典型的结构是:
scheduler-google-auth-demo/
client/
index.ejs
main.ts
styles.css
server/
config/
index.ts
passport.ts
routes/
auth.route.ts
events.route.ts
services/
googleService.ts
mappers/
eventMapper.ts
rollup.config.js
package.json
.env.example
- server/:OAuth、会话中的令牌存储、Google Calendar API 调用,以及 Scheduler 的 REST 端点
- client/:Scheduler 初始化和加载,以及 DataProcessor 将 CRUD 操作转发到服务器
步骤 2 - 实现 Google OAuth(Express 会话 + Passport)
在此步骤中,你将使后端能够对用户进行认证并在会话中存储 Google 的访问令牌/刷新令牌。
2.1 引导服务器(会话 + passport)
更新 server/index.ts 以启用会话和 passport,然后挂载路由。
以下是核心连接(仅展示相关部分):
app.use(
session({
secret: config.SESSION_SECRET || "fallback-secret-for-dev",
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
sameSite: "lax",
},
})
);
app.use(passport.initialize());
app.use(passport.session());
app.use("/events", (req, res, next) => {
req.isAuthenticated() ? next() : res.status(401).json({ error: "Not authenticated" });
}, eventsRoute);
app.use("/auth", authRoute);
app.get("/", (req, res) => {
res.render("index", { googleAuth: req.isAuthenticated() });
});
在 /events 上的行内中间件确保未认证的请求在路由处理程序将 req 转换为 AuthenticatedRequest 时不会导致服务器崩溃,而是返回 401 响应。
2.2 配置 Google 策略
更新 server/config/passport.ts,注册 passport-google-oauth20。核心思路是:在会话中存储的用户对象上保留 accessToken 和 refreshToken:
passport.serializeUser((user: Express.User, done) => {
done(null, user);
});
passport.deserializeUser((obj: Express.User, done) => {
done(null, obj);
});
passport.use(
new GoogleStrategy(
{
clientID: config.GOOGLE_CLIENT_ID || "",
clientSecret: config.GOOGLE_CLIENT_SECRET || "",
callbackURL: config.GOOGLE_REDIRECT_URI || "",
},
(accessToken, refreshToken, profile, done) => {
const user = {
id: profile.id,
displayName: profile.displayName,
tokens: { accessToken, refreshToken },
};
done(null, user as unknown as Express.User);
}
)
);
生产环境中通常会把令牌持久化到数据库,并实现刷新令牌的轮换/撤销。本示例将令牌保存在会话中,以便于理解。
2.3 添加 OAuth 路由
更新 server/routes/auth.route.ts,暴露 OAuth 入口、回调和登出:
router.get(
"/google",
passport.authenticate("google", {
scope: ["profile", "email", "https://www.googleapis.com/auth/calendar"],
accessType: "offline",
prompt: "consent",
})
);
router.get(
"/google/callback",
passport.authenticate("google", { failureRedirect: "/login" }),
(_req, res) => res.redirect("/")
);
router.get("/google/logout", (req, res, next) => {
req.logout((err) => (err ? next(err) : res.redirect("/")));
});
到此,你应能够访问 /auth/google,完成 Google 同意屏幕并带着已认证的会话返回到 /。
创建 Google 日历服务层
创建 server/services/googleService.ts,封装 Google Calendar API v3 的 CRUD 方法。它从会话令牌中创建 OAuth2 客户端,并暴露列出日历、列出事件、以及创建/更新/删除事件的帮助函数:
import { google, calendar_v3 } from "googleapis";
import type { GoogleOAuthTokens } from "../types/auth.types.ts";
import config from "../config/index.ts";
const calendarClient = google.calendar("v3");
function oauthClient(tokens: GoogleOAuthTokens) {
const client = new google.auth.OAuth2(
config.GOOGLE_CLIENT_ID,
config.GOOGLE_CLIENT_SECRET,
config.GOOGLE_REDIRECT_URI
);
client.setCredentials({
access_token: tokens.accessToken,
refresh_token: tokens.refreshToken,
});
return client;
}
/* ------ CRUD helpers ------- */
export async function listCalendars(tokens: GoogleOAuthTokens): Promise<calendar_v3.Schema$CalendarListEntry[]> {
const { data } = await calendarClient.calendarList.list({ auth: oauthClient(tokens) });
return data.items ?? [];
}
export async function listEvents(
tokens: GoogleOAuthTokens,
opts: calendar_v3.Params$Resource$Events$List
): Promise<calendar_v3.Schema$Event[]> {
const { data } = await calendarClient.events.list({
auth: oauthClient(tokens),
...opts,
});
return data.items ?? [];
}
export async function createEvent(
tokens: GoogleOAuthTokens,
calendarId: string | undefined,
gEvent: calendar_v3.Schema$Event
): Promise<calendar_v3.Schema$Event> {
const { data } = await calendarClient.events.insert({
auth: oauthClient(tokens),
calendarId: calendarId || "primary",
requestBody: gEvent,
conferenceDataVersion: 1,
});
return data;
}
export async function updateEvent(
tokens: GoogleOAuthTokens,
calendarId: string | undefined,
eventId: string,
gPatch: calendar_v3.Schema$Event
): Promise<calendar_v3.Schema$Event> {
const { data } = await calendarClient.events.patch({
auth: oauthClient(tokens),
calendarId: calendarId || "primary",
eventId,
requestBody: gPatch,
});
return data;
}
export async function deleteEvent(
tokens: GoogleOAuthTokens,
calendarId: string | undefined,
eventId: string
): Promise<void> {
await calendarClient.events.delete({
auth: oauthClient(tokens),
calendarId: calendarId || "primary",
eventId,
});
}
步骤 3 - 为 Scheduler CRUD 暴露 REST API
在此步骤中,你将实现 Scheduler 使用的 API 合同:
GET /events- 加载日历 + 事件POST /events- 创建PUT /events/:eventId- 更新DELETE /events/:eventId- 删除
3.1 加载日历 + 事件(GET /events)
更新 server/routes/events.route.ts,返回:
data:包含 Scheduler 风格的事件collections.calendars:包含日历列表,客户端将可用该列表(详见 加载数据)
以下为一个工作示例处理程序:
router.get("/", async (req, res, next) => {
const authedReq = req as AuthenticatedRequest;
try {
const calendars = await googleService.listCalendars(authedReq.user.tokens);
const mappedCals = calendars
.filter((calendar) => Boolean(calendar.id))
.map((calendar) => ({
id: calendar.id as string,
key: calendar.id as string,
label: calendar.summary ?? "",
backgroundColor: calendar.backgroundColor ?? undefined,
}));
const fromQuery = typeof req.query.from === "string" ? req.query.from : undefined;
const toQuery = typeof req.query.to === "string" ? req.query.to : undefined;
const minDate = fromQuery ? new Date(fromQuery).toISOString() : new Date().toISOString();
const maxDate = toQuery ? new Date(toQuery).toISOString() : undefined;
const googleEvents = await Promise.all(
mappedCals.map(async (calendar) => {
const params: calendar_v3.Params$Resource$Events$List = { calendarId: calendar.id, timeMin: minDate };
if (maxDate) params.timeMax = maxDate;
const calendarEventsResponse = await googleService.listEvents(authedReq.user.tokens, params);
return (calendarEventsResponse as Array<Record<string, unknown>>).map((event) =>
toDhxEvent(event as calendar_v3.Schema$Event, calendar)
);
})
);
res.json({
success: true,
data: googleEvents.flat(),
collections: { calendars: mappedCals },
});
} catch (error) {
next(error);
}
});