diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/.npmignore b/packages/plugins/@nocobase/plugin-user-data-sync/.npmignore
new file mode 100644
index 0000000000..65f5e8779f
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/.npmignore
@@ -0,0 +1,2 @@
+/node_modules
+/src
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/README.md b/packages/plugins/@nocobase/plugin-user-data-sync/README.md
new file mode 100644
index 0000000000..13dcf98789
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/README.md
@@ -0,0 +1 @@
+# @nocobase/plugin-user-data-sync
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/client.d.ts b/packages/plugins/@nocobase/plugin-user-data-sync/client.d.ts
new file mode 100644
index 0000000000..6c459cbac4
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/client.d.ts
@@ -0,0 +1,2 @@
+export * from './dist/client';
+export { default } from './dist/client';
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/client.js b/packages/plugins/@nocobase/plugin-user-data-sync/client.js
new file mode 100644
index 0000000000..b6e3be70e6
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/client.js
@@ -0,0 +1 @@
+module.exports = require('./dist/client/index.js');
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/package.json b/packages/plugins/@nocobase/plugin-user-data-sync/package.json
new file mode 100644
index 0000000000..039d36eefc
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@nocobase/plugin-user-data-sync",
+ "displayName": "User data synchronization",
+ "displayName.zh-CN": "用户数据同步",
+ "description": "Provide user data source management and user data synchronization interface. The data source can be DingTalk, WeCom, etc., and can be expanded.",
+ "description.zh-CN": "提供用户数据源管理,用户数据同步接口,数据源可为钉钉、企业微信等,可扩展。",
+ "version": "1.4.0-alpha",
+ "main": "dist/server/index.js",
+ "dependencies": {},
+ "peerDependencies": {
+ "@nocobase/client": "1.x",
+ "@nocobase/server": "1.x",
+ "@nocobase/test": "1.x"
+ },
+ "keywords": [
+ "Users & permissions"
+ ]
+}
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/server.d.ts b/packages/plugins/@nocobase/plugin-user-data-sync/server.d.ts
new file mode 100644
index 0000000000..c41081ddc6
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/server.d.ts
@@ -0,0 +1,2 @@
+export * from './dist/server';
+export { default } from './dist/server';
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/server.js b/packages/plugins/@nocobase/plugin-user-data-sync/server.js
new file mode 100644
index 0000000000..972842039a
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/server.js
@@ -0,0 +1 @@
+module.exports = require('./dist/server/index.js');
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/client/Options.tsx b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/Options.tsx
new file mode 100644
index 0000000000..eb5070ed6d
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/Options.tsx
@@ -0,0 +1,54 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import React from 'react';
+import { observer, useForm } from '@formily/react';
+import { useActionContext, useCollectionRecordData, usePlugin, useRequest } from '@nocobase/client';
+import { useEffect } from 'react';
+import SourcePlugin from '.';
+
+export const useValuesFromOptions = (options) => {
+ const record = useCollectionRecordData();
+ const result = useRequest(
+ () =>
+ Promise.resolve({
+ data: {
+ ...record.options,
+ },
+ }),
+ {
+ ...options,
+ manual: true,
+ },
+ );
+ const { run } = result;
+ const ctx = useActionContext();
+ useEffect(() => {
+ if (ctx.visible) {
+ run();
+ }
+ }, [ctx.visible, run]);
+ return result;
+};
+
+export const useAdminSettingsForm = (sourceType: string) => {
+ const plugin = usePlugin(SourcePlugin);
+ const source = plugin.sourceTypes.get(sourceType);
+ return source?.components?.AdminSettingsForm;
+};
+
+export const Options = observer(
+ () => {
+ const form = useForm();
+ const record = useCollectionRecordData();
+ const Component = useAdminSettingsForm(form.values.sourceType || record.sourceType);
+ return Component ? : null;
+ },
+ { displayName: 'Options' },
+);
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/client/UserDataSyncSource.tsx b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/UserDataSyncSource.tsx
new file mode 100644
index 0000000000..83c35bcd08
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/UserDataSyncSource.tsx
@@ -0,0 +1,284 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import {
+ ActionContextProvider,
+ SchemaComponent,
+ useAPIClient,
+ useActionContext,
+ useRequest,
+ ExtendCollectionsProvider,
+ useDataBlockRequest,
+ useDataBlockResource,
+ useCollection,
+ useCollectionRecordData,
+ ActionProps,
+} from '@nocobase/client';
+import { App as AntdApp } from 'antd';
+import React, { useContext, useMemo, useState } from 'react';
+import {
+ userDataSyncSourcesSchema,
+ createFormSchema,
+ sourceCollection,
+ tasksTableBlockSchema,
+} from './schemas/user-data-sync-sources';
+import { Button, Dropdown, Empty } from 'antd';
+import { PlusOutlined, DownOutlined } from '@ant-design/icons';
+import { SourceTypeContext, SourceTypesContext, useSourceTypes } from './sourceType';
+import { useValuesFromOptions, Options } from './Options';
+import { NAMESPACE, useUserDataSyncSourceTranslation } from './locale';
+import { Schema, useForm } from '@formily/react';
+import { taskCollection } from './schemas/user-data-sync-sources';
+import { createForm } from '@formily/core';
+
+const useEditFormProps = () => {
+ const recordData = useCollectionRecordData();
+ const form = useMemo(
+ () =>
+ createForm({
+ values: recordData,
+ }),
+ [],
+ );
+
+ return {
+ form,
+ };
+};
+
+const useSubmitActionProps = () => {
+ const { setVisible } = useActionContext();
+ const { message } = AntdApp.useApp();
+ const form = useForm();
+ const resource = useDataBlockResource();
+ const { runAsync } = useDataBlockRequest();
+ const collection = useCollection();
+
+ return {
+ type: 'primary',
+ async onClick() {
+ await form.submit();
+ const values = form.values;
+ if (values[collection.filterTargetKey]) {
+ await resource.update({
+ values,
+ filterByTk: values[collection.filterTargetKey],
+ });
+ } else {
+ await resource.create({ values });
+ }
+ await runAsync();
+ message.success('Saved successfully');
+ setVisible(false);
+ },
+ };
+};
+
+function useDeleteActionProps(): ActionProps {
+ const { message } = AntdApp.useApp();
+ const record = useCollectionRecordData();
+ const resource = useDataBlockResource();
+ const collection = useCollection();
+ const { runAsync } = useDataBlockRequest();
+ return {
+ confirm: {
+ title: 'Delete',
+ content: 'Are you sure you want to delete it?',
+ },
+ async onClick() {
+ await resource.destroy({
+ filterByTk: record[collection.filterTargetKey],
+ });
+ await runAsync();
+ message.success('Deleted!');
+ },
+ };
+}
+
+function useSyncActionProps(): ActionProps {
+ const { message } = AntdApp.useApp();
+ const record = useCollectionRecordData();
+ const api = useAPIClient();
+ const { runAsync } = useDataBlockRequest();
+ return {
+ async onClick() {
+ await api.resource('userData').pull({ name: record['name'] });
+ await runAsync();
+ message.success('Synced!');
+ },
+ };
+}
+
+const useCustomFormProps = () => {
+ const { type: sourceType } = useContext(SourceTypeContext);
+ const form = useMemo(
+ () =>
+ createForm({
+ initialValues: {
+ sourceType: sourceType,
+ },
+ }),
+ [],
+ );
+ return {
+ form,
+ };
+};
+
+const useTasksTableBlockProps = () => {
+ const record = useCollectionRecordData();
+ const collection = useCollection();
+ return {
+ params: {
+ pageSize: 20,
+ filter: {
+ sourceId: record[collection.filterTargetKey],
+ },
+ sort: ['-sort'],
+ },
+ };
+};
+
+function useRetryActionProps(): ActionProps {
+ const { message } = AntdApp.useApp();
+ const record = useCollectionRecordData();
+ const resource = useDataBlockResource();
+ const collection = useCollection();
+ const api = useAPIClient();
+ const { runAsync } = useDataBlockRequest();
+ return {
+ async onClick() {
+ await api.resource('userData').retry({ id: record[collection.filterTargetKey], sourceId: record['sourceId'] });
+ await runAsync();
+ message.success('Successfully');
+ },
+ };
+}
+
+const AddNew = () => {
+ const { t } = useUserDataSyncSourceTranslation();
+ const api = useAPIClient();
+ const [visible, setVisible] = useState(false);
+ const [type, setType] = useState('');
+ const types = useSourceTypes();
+ const items = types.map((item) => ({
+ ...item,
+ onClick: () => {
+ setVisible(true);
+ setType(item.value);
+ },
+ }));
+
+ const emptyItem = [
+ {
+ key: '__empty__',
+ label: (
+
+ {t('No user data source plugin installed', { ns: NAMESPACE })}
+
{' '}
+
+ {t('View documentation', { ns: NAMESPACE })}
+
+ >
+ }
+ />
+ ),
+ onClick: () => {},
+ },
+ ];
+
+ return (
+
+
+ 0 ? items : emptyItem }}>
+ } type={'primary'}>
+ {t('Add new')}
+
+
+
+
+
+ );
+};
+
+const Tasks = () => {
+ const { t } = useUserDataSyncSourceTranslation();
+ const [visible, setVisible] = useState(false);
+ return (
+
+
+
+
+
+
+ );
+};
+
+export const UserDataSyncSource: React.FC = () => {
+ const { t } = useUserDataSyncSourceTranslation();
+ const [types, setTypes] = useState([]);
+ const api = useAPIClient();
+ useRequest(
+ () =>
+ api
+ .resource('userData')
+ .listSyncTypes()
+ .then((res) => {
+ const types = res?.data?.data || [];
+ return types.map((type: { name: string; title?: string }) => ({
+ key: type.name,
+ label: Schema.compile(type.title || type.name, { t }),
+ value: type.name,
+ }));
+ }),
+ {
+ onSuccess: (types) => {
+ setTypes(types);
+ },
+ },
+ );
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/client/client.d.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/client.d.ts
new file mode 100644
index 0000000000..4e96f83fa1
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/client.d.ts
@@ -0,0 +1,249 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+// CSS modules
+type CSSModuleClasses = { readonly [key: string]: string };
+
+declare module '*.module.css' {
+ const classes: CSSModuleClasses;
+ export default classes;
+}
+declare module '*.module.scss' {
+ const classes: CSSModuleClasses;
+ export default classes;
+}
+declare module '*.module.sass' {
+ const classes: CSSModuleClasses;
+ export default classes;
+}
+declare module '*.module.less' {
+ const classes: CSSModuleClasses;
+ export default classes;
+}
+declare module '*.module.styl' {
+ const classes: CSSModuleClasses;
+ export default classes;
+}
+declare module '*.module.stylus' {
+ const classes: CSSModuleClasses;
+ export default classes;
+}
+declare module '*.module.pcss' {
+ const classes: CSSModuleClasses;
+ export default classes;
+}
+declare module '*.module.sss' {
+ const classes: CSSModuleClasses;
+ export default classes;
+}
+
+// CSS
+declare module '*.css' { }
+declare module '*.scss' { }
+declare module '*.sass' { }
+declare module '*.less' { }
+declare module '*.styl' { }
+declare module '*.stylus' { }
+declare module '*.pcss' { }
+declare module '*.sss' { }
+
+// Built-in asset types
+// see `src/node/constants.ts`
+
+// images
+declare module '*.apng' {
+ const src: string;
+ export default src;
+}
+declare module '*.png' {
+ const src: string;
+ export default src;
+}
+declare module '*.jpg' {
+ const src: string;
+ export default src;
+}
+declare module '*.jpeg' {
+ const src: string;
+ export default src;
+}
+declare module '*.jfif' {
+ const src: string;
+ export default src;
+}
+declare module '*.pjpeg' {
+ const src: string;
+ export default src;
+}
+declare module '*.pjp' {
+ const src: string;
+ export default src;
+}
+declare module '*.gif' {
+ const src: string;
+ export default src;
+}
+declare module '*.svg' {
+ const src: string;
+ export default src;
+}
+declare module '*.ico' {
+ const src: string;
+ export default src;
+}
+declare module '*.webp' {
+ const src: string;
+ export default src;
+}
+declare module '*.avif' {
+ const src: string;
+ export default src;
+}
+
+// media
+declare module '*.mp4' {
+ const src: string;
+ export default src;
+}
+declare module '*.webm' {
+ const src: string;
+ export default src;
+}
+declare module '*.ogg' {
+ const src: string;
+ export default src;
+}
+declare module '*.mp3' {
+ const src: string;
+ export default src;
+}
+declare module '*.wav' {
+ const src: string;
+ export default src;
+}
+declare module '*.flac' {
+ const src: string;
+ export default src;
+}
+declare module '*.aac' {
+ const src: string;
+ export default src;
+}
+declare module '*.opus' {
+ const src: string;
+ export default src;
+}
+declare module '*.mov' {
+ const src: string;
+ export default src;
+}
+declare module '*.m4a' {
+ const src: string;
+ export default src;
+}
+declare module '*.vtt' {
+ const src: string;
+ export default src;
+}
+
+// fonts
+declare module '*.woff' {
+ const src: string;
+ export default src;
+}
+declare module '*.woff2' {
+ const src: string;
+ export default src;
+}
+declare module '*.eot' {
+ const src: string;
+ export default src;
+}
+declare module '*.ttf' {
+ const src: string;
+ export default src;
+}
+declare module '*.otf' {
+ const src: string;
+ export default src;
+}
+
+// other
+declare module '*.webmanifest' {
+ const src: string;
+ export default src;
+}
+declare module '*.pdf' {
+ const src: string;
+ export default src;
+}
+declare module '*.txt' {
+ const src: string;
+ export default src;
+}
+
+// wasm?init
+declare module '*.wasm?init' {
+ const initWasm: (options?: WebAssembly.Imports) => Promise;
+ export default initWasm;
+}
+
+// web worker
+declare module '*?worker' {
+ const workerConstructor: {
+ new(options?: { name?: string }): Worker;
+ };
+ export default workerConstructor;
+}
+
+declare module '*?worker&inline' {
+ const workerConstructor: {
+ new(options?: { name?: string }): Worker;
+ };
+ export default workerConstructor;
+}
+
+declare module '*?worker&url' {
+ const src: string;
+ export default src;
+}
+
+declare module '*?sharedworker' {
+ const sharedWorkerConstructor: {
+ new(options?: { name?: string }): SharedWorker;
+ };
+ export default sharedWorkerConstructor;
+}
+
+declare module '*?sharedworker&inline' {
+ const sharedWorkerConstructor: {
+ new(options?: { name?: string }): SharedWorker;
+ };
+ export default sharedWorkerConstructor;
+}
+
+declare module '*?sharedworker&url' {
+ const src: string;
+ export default src;
+}
+
+declare module '*?raw' {
+ const src: string;
+ export default src;
+}
+
+declare module '*?url' {
+ const src: string;
+ export default src;
+}
+
+declare module '*?inline' {
+ const src: string;
+ export default src;
+}
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/client/index.tsx b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/index.tsx
new file mode 100644
index 0000000000..2d6b47cd4e
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/index.tsx
@@ -0,0 +1,41 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { Plugin } from '@nocobase/client';
+import { Registry, tval } from '@nocobase/utils/client';
+import { ComponentType } from 'react';
+import { NAMESPACE } from './locale';
+import { UserDataSyncSource } from './UserDataSyncSource';
+
+export type SourceOptions = {
+ components: Partial<{
+ AdminSettingsForm: ComponentType;
+ }>;
+};
+
+export class PluginUserDataSyncClient extends Plugin {
+ sourceTypes = new Registry();
+
+ registerType(sourceType: string, options: SourceOptions) {
+ this.sourceTypes.register(sourceType, options);
+ }
+
+ // You can get and modify the app instance here
+ async load() {
+ this.app.pluginSettingsManager.add('users-permissions.sync', {
+ title: tval('Synchronize', { ns: NAMESPACE }),
+ icon: 'SyncOutlined',
+ Component: UserDataSyncSource,
+ sort: 99,
+ aclSnippet: 'pm.user-data-sync',
+ });
+ }
+}
+
+export default PluginUserDataSyncClient;
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/client/locale/index.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/locale/index.ts
new file mode 100644
index 0000000000..2b94158816
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/locale/index.ts
@@ -0,0 +1,16 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { useTranslation } from 'react-i18next';
+
+export const NAMESPACE = 'user-data-sync';
+
+export function useUserDataSyncSourceTranslation() {
+ return useTranslation([NAMESPACE, 'client'], { nsMode: 'fallback' });
+}
\ No newline at end of file
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/client/schemas/user-data-sync-sources.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/schemas/user-data-sync-sources.ts
new file mode 100644
index 0000000000..107d976c37
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/schemas/user-data-sync-sources.ts
@@ -0,0 +1,523 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { ISchema } from '@nocobase/client';
+
+export const sourceCollection = {
+ name: 'userDataSyncSources',
+ sortable: true,
+ filterTargetKey: 'id',
+ fields: [
+ {
+ name: 'id',
+ type: 'string',
+ interface: 'id',
+ },
+ {
+ interface: 'input',
+ type: 'string',
+ name: 'name',
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Source name")}}',
+ 'x-component': 'Input',
+ required: true,
+ },
+ },
+ {
+ interface: 'input',
+ type: 'string',
+ name: 'sourceType',
+ allowNull: false,
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Type")}}',
+ 'x-component': 'Select',
+ required: true,
+ dataSource: '{{ types }}',
+ },
+ },
+ // {
+ // interface: 'input',
+ // type: 'string',
+ // name: 'displayName',
+ // uiSchema: {
+ // type: 'string',
+ // title: '{{t("Source display name")}}',
+ // 'x-component': 'Input',
+ // },
+ // },
+ {
+ type: 'boolean',
+ name: 'enabled',
+ uiSchema: {
+ type: 'boolean',
+ title: '{{t("Enabled")}}',
+ 'x-component': 'Checkbox',
+ },
+ },
+ ],
+};
+
+export const taskCollection = {
+ name: 'userDataSyncTasks',
+ filterTargetKey: 'id',
+ fields: [
+ {
+ name: 'id',
+ type: 'bigInt',
+ interface: 'id',
+ },
+ {
+ name: 'batch',
+ interface: 'input',
+ type: 'string',
+ allowNull: false,
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Batch")}}',
+ 'x-component': 'Input',
+ required: true,
+ },
+ },
+ {
+ name: 'source',
+ interface: 'input',
+ type: 'belongsTo',
+ target: 'userDataSyncSources',
+ targetKey: 'id',
+ foreignKey: 'sourceId',
+ allowNull: false,
+ uiSchema: {
+ type: 'object',
+ title: '{{t("Source")}}',
+ 'x-component': 'AssociationField',
+ 'x-component-props': {
+ fieldNames: {
+ value: 'id',
+ label: 'name',
+ },
+ },
+ required: true,
+ 'x-read-pretty': true,
+ },
+ },
+ {
+ name: 'status',
+ interface: 'input',
+ type: 'string',
+ allowNull: false,
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Status")}}',
+ 'x-component': 'Select',
+ required: true,
+ enum: [
+ { label: '{{t("Init")}}', value: 'init', color: 'default' },
+ { label: '{{t("Processing")}}', value: 'processing', color: 'processing' },
+ { label: '{{t("Success")}}', value: 'success', color: 'success' },
+ { label: '{{t("Failed")}}', value: 'failed', color: 'error' },
+ ],
+ },
+ },
+ {
+ name: 'message',
+ interface: 'input',
+ type: 'string',
+ allowNull: true,
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Message")}}',
+ 'x-component': 'Input',
+ required: false,
+ },
+ },
+ {
+ name: 'cost',
+ interface: 'input',
+ type: 'integer',
+ allowNull: true,
+ uiSchema: {
+ type: 'integer',
+ title: '{{t("Cost")}}',
+ 'x-component': 'InputNumber',
+ 'x-component-props': {
+ precision: 0,
+ },
+ required: false,
+ },
+ },
+ ],
+};
+
+export const createFormSchema: ISchema = {
+ type: 'object',
+ properties: {
+ drawer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer',
+ title: '{{t("Add new")}}',
+ properties: {
+ form: {
+ type: 'void',
+ 'x-component': 'FormV2',
+ 'x-use-component-props': 'useCustomFormProps',
+ properties: {
+ name: {
+ 'x-component': 'CollectionField',
+ 'x-decorator': 'FormItem',
+ },
+ sourceType: {
+ 'x-component': 'CollectionField',
+ 'x-decorator': 'FormItem',
+ 'x-component-props': {
+ options: '{{ types }}',
+ },
+ },
+ // displayName: {
+ // 'x-component': 'CollectionField',
+ // 'x-decorator': 'FormItem',
+ // },
+ enabled: {
+ 'x-component': 'CollectionField',
+ 'x-decorator': 'FormItem',
+ },
+ options: {
+ type: 'object',
+ 'x-component': 'Options',
+ },
+ footer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer.Footer',
+ properties: {
+ submit: {
+ title: '{{t("Submit")}}',
+ 'x-component': 'Action',
+ 'x-use-component-props': 'useSubmitActionProps',
+ 'x-component-props': {
+ type: 'primary',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+};
+
+export const tasksTableBlockSchema: ISchema = {
+ type: 'object',
+ properties: {
+ drawer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer',
+ title: '{{ t("Tasks") }}',
+ properties: {
+ table: {
+ type: 'void',
+ 'x-decorator': 'TableBlockProvider',
+ 'x-use-decorator-props': 'useTasksTableBlockProps',
+ 'x-decorator-props': {
+ collection: taskCollection.name,
+ dragSort: false,
+ action: 'list',
+ showIndex: true,
+ },
+ properties: {
+ table: {
+ type: 'array',
+ 'x-component': 'TableV2',
+ 'x-use-component-props': 'useTableBlockProps',
+ 'x-component-props': {
+ rowKey: 'id',
+ },
+ properties: {
+ batch: {
+ type: 'void',
+ title: '{{ t("Batch") }}',
+ 'x-component': 'TableV2.Column',
+ properties: {
+ batch: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-pattern': 'readPretty',
+ },
+ },
+ },
+ status: {
+ type: 'void',
+ title: '{{ t("Status") }}',
+ 'x-component': 'TableV2.Column',
+ properties: {
+ status: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-pattern': 'readPretty',
+ },
+ },
+ },
+ message: {
+ type: 'void',
+ title: '{{ t("Message") }}',
+ 'x-component': 'TableV2.Column',
+ properties: {
+ message: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-pattern': 'readPretty',
+ },
+ },
+ },
+ actions: {
+ type: 'void',
+ title: '{{t("Actions")}}',
+ 'x-decorator': 'TableV2.Column.ActionBar',
+ 'x-component': 'TableV2.Column',
+ properties: {
+ actions: {
+ type: 'void',
+ 'x-component': 'Space',
+ 'x-component-props': {
+ split: '|',
+ },
+ properties: {
+ sync: {
+ type: 'void',
+ title: '{{ t("Retry") }}',
+ 'x-component': 'Action.Link',
+ 'x-use-component-props': 'useRetryActionProps',
+ 'x-display': '{{ $record.status === "failed" ? "visible" : "hidden" }}',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+};
+
+export const userDataSyncSourcesSchema: ISchema = {
+ type: 'void',
+ name: 'userDataSyncSources',
+ 'x-component': 'CardItem',
+ 'x-decorator': 'TableBlockProvider',
+ 'x-decorator-props': {
+ collection: sourceCollection.name,
+ dragSort: false,
+ action: 'list',
+ params: {
+ pageSize: 10,
+ },
+ showIndex: true,
+ },
+ properties: {
+ actions: {
+ type: 'void',
+ 'x-component': 'ActionBar',
+ 'x-component-props': {
+ style: {
+ marginBottom: 16,
+ },
+ },
+ properties: {
+ delete: {
+ type: 'void',
+ title: '{{t("Delete")}}',
+ 'x-component': 'Action',
+ 'x-use-component-props': 'useBulkDestroyActionProps',
+ 'x-component-props': {
+ icon: 'DeleteOutlined',
+ confirm: {
+ title: "{{t('Delete')}}",
+ content: "{{t('Are you sure you want to delete it?')}}",
+ },
+ },
+ },
+ create: {
+ type: 'void',
+ title: '{{t("Add new")}}',
+ 'x-component': 'AddNew',
+ 'x-component-props': {
+ type: 'primary',
+ },
+ },
+ },
+ },
+ table: {
+ type: 'array',
+ 'x-component': 'TableV2',
+ 'x-use-component-props': 'useTableBlockProps',
+ 'x-component-props': {
+ rowKey: 'id',
+ rowSelection: {
+ type: 'checkbox',
+ },
+ },
+ properties: {
+ name: {
+ type: 'void',
+ title: '{{t("Source name")}}',
+ 'x-component': 'TableV2.Column',
+ properties: {
+ name: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-pattern': 'readPretty',
+ },
+ },
+ },
+ // displayName: {
+ // type: 'void',
+ // title: '{{t("Source display name")}}',
+ // 'x-component': 'TableV2.Column',
+ // properties: {
+ // displayName: {
+ // type: 'string',
+ // 'x-component': 'CollectionField',
+ // 'x-pattern': 'readPretty',
+ // },
+ // },
+ // },
+ sourceType: {
+ type: 'void',
+ title: '{{t("Type")}}',
+ 'x-component': 'TableV2.Column',
+ properties: {
+ sourceType: {
+ type: 'string',
+ 'x-component': 'Select',
+ 'x-pattern': 'readPretty',
+ enum: '{{ types }}',
+ },
+ },
+ },
+ enabled: {
+ type: 'void',
+ title: '{{t("Enabled")}}',
+ 'x-component': 'TableV2.Column',
+ properties: {
+ enabled: {
+ type: 'boolean',
+ 'x-component': 'CollectionField',
+ 'x-pattern': 'readPretty',
+ },
+ },
+ },
+ actions: {
+ type: 'void',
+ title: '{{t("Actions")}}',
+ 'x-decorator': 'TableV2.Column.ActionBar',
+ 'x-component': 'TableV2.Column',
+ properties: {
+ actions: {
+ type: 'void',
+ 'x-component': 'Space',
+ 'x-component-props': {
+ split: '|',
+ },
+ properties: {
+ sync: {
+ type: 'void',
+ title: '{{ t("Sync") }}',
+ 'x-component': 'Action.Link',
+ 'x-use-component-props': 'useSyncActionProps',
+ 'x-display': '{{ $record.enabled ? "visible" : "hidden" }}',
+ },
+ tasks: {
+ type: 'void',
+ title: '{{ t("Tasks") }}',
+ 'x-component': 'Tasks',
+ 'x-component-props': {
+ type: 'primary',
+ },
+ 'x-display': '{{ $record.enabled ? "visible" : "hidden" }}',
+ },
+ edit: {
+ type: 'void',
+ title: '{{t("Configure")}}',
+ 'x-component': 'Action.Link',
+ 'x-component-props': {
+ type: 'primary',
+ openMode: 'drawer',
+ },
+ properties: {
+ drawer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer',
+ title: '{{t("Configure")}}',
+ properties: {
+ form: {
+ type: 'void',
+ 'x-component': 'FormV2',
+ 'x-use-component-props': 'useEditFormProps',
+ properties: {
+ name: {
+ 'x-component': 'CollectionField',
+ 'x-decorator': 'FormItem',
+ },
+ sourceType: {
+ 'x-component': 'CollectionField',
+ 'x-decorator': 'FormItem',
+ 'x-component-props': {
+ options: '{{ types }}',
+ },
+ },
+ // displayName: {
+ // 'x-component': 'CollectionField',
+ // 'x-decorator': 'FormItem',
+ // },
+ enabled: {
+ 'x-component': 'CollectionField',
+ 'x-decorator': 'FormItem',
+ },
+ options: {
+ type: 'object',
+ 'x-component': 'Options',
+ },
+ footer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer.Footer',
+ properties: {
+ submit: {
+ title: '{{t("Submit")}}',
+ 'x-component': 'Action',
+ 'x-use-component-props': 'useSubmitActionProps',
+ 'x-component-props': {
+ type: 'primary',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ delete: {
+ type: 'void',
+ title: '{{ t("Delete") }}',
+ 'x-component': 'Action.Link',
+ 'x-use-component-props': 'useDeleteActionProps',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+};
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/client/sourceType.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/sourceType.ts
new file mode 100644
index 0000000000..4c5845da78
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/client/sourceType.ts
@@ -0,0 +1,27 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { createContext, useContext } from 'react';
+
+export const SourceTypeContext = createContext<{ type: string }>({ type: '' });
+SourceTypeContext.displayName = 'SourceTypeContext';
+
+export const SourceTypesContext = createContext<{
+ types: {
+ key: string;
+ label: string;
+ value: string;
+ }[];
+}>({ types: [] });
+SourceTypesContext.displayName = 'SourceTypesContext';
+
+export const useSourceTypes = () => {
+ const { types } = useContext(SourceTypesContext);
+ return types;
+};
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/index.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/index.ts
new file mode 100644
index 0000000000..53bc727360
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/index.ts
@@ -0,0 +1,11 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+export * from './server';
+export { default, UserDataResource, FormatUser, SyncAccept, OriginRecord } from './server';
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-user-data-sync/src/locale/en-US.json
new file mode 100644
index 0000000000..bd58e56b76
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/locale/en-US.json
@@ -0,0 +1,19 @@
+{
+ "Synchronize": "Synchronize",
+ "Source name": "Source name",
+ "Source display name": "Source display name",
+ "Type": "Type",
+ "Sync": "Sync",
+ "Tasks": "Tasks",
+ "Batch": "Batch",
+ "Status": "Status",
+ "Message": "Message",
+ "Init": "Init",
+ "Processing": "Processing",
+ "Success": "Success",
+ "Failed": "Failed",
+ "Authenticator": "Authenticator",
+ "Retry": "Retry",
+ "No user data source plugin installed": "No user data source plugin installed",
+ "View documentation": "View documentation"
+}
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-user-data-sync/src/locale/zh-CN.json
new file mode 100644
index 0000000000..dc72473030
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/locale/zh-CN.json
@@ -0,0 +1,22 @@
+{
+ "Synchronize": "同步",
+ "Source name": "数据源名称",
+ "Source display name": "数据源展示名称",
+ "Type": "类型",
+ "Sync": "同步",
+ "Tasks": "任务",
+ "Batch": "批次",
+ "Status": "状态",
+ "Message": "信息",
+ "Init": "初始化",
+ "Processing": "进行中",
+ "Success": "成功",
+ "Failed": "失败",
+ "Authenticator": "用户认证",
+ "dingtalk": "钉钉",
+ "wecom": "企业微信",
+ "default": "默认",
+ "Retry": "重试",
+ "No user data source plugin installed": "未安装同步数据源",
+ "View documentation": "查看文档"
+}
\ No newline at end of file
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/__tests__/api.test.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/__tests__/api.test.ts
new file mode 100644
index 0000000000..dd066ead86
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/__tests__/api.test.ts
@@ -0,0 +1,54 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { MockServer, createMockServer } from '@nocobase/test';
+import { UserDataResourceManager } from '../user-data-resource-manager';
+import { MockUsersResource } from './mock-resource';
+import PluginUserDataSyncServer from '../plugin';
+
+describe('api', async () => {
+ let app: MockServer;
+ let agent: any;
+ let resourceManager: UserDataResourceManager;
+
+ beforeEach(async () => {
+ app = await createMockServer({
+ plugins: ['user-data-sync'],
+ });
+ agent = app.agent();
+ const plugin = app.pm.get('user-data-sync') as PluginUserDataSyncServer;
+ resourceManager = plugin.resourceManager;
+ });
+
+ afterEach(async () => {
+ await app.destroy();
+ });
+
+ it('push data', async () => {
+ const usersResource = new MockUsersResource(app.db, app.logger);
+ resourceManager.registerResource(usersResource);
+ const res = await agent.resource('userData').push({
+ values: {
+ dataType: 'user',
+ records: [
+ {
+ uid: '1',
+ nickname: 'test',
+ },
+ ],
+ },
+ });
+ expect(res.status).toBe(200);
+ expect(usersResource.data.length).toBe(1);
+ expect(usersResource.data[0]).toMatchObject({
+ uid: '1',
+ nickname: 'test',
+ });
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/__tests__/mock-resource.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/__tests__/mock-resource.ts
new file mode 100644
index 0000000000..caf964996a
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/__tests__/mock-resource.ts
@@ -0,0 +1,41 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import {
+ OriginRecord,
+ PrimaryKey,
+ RecordResourceChanged,
+ SyncAccept,
+ UserDataResource,
+} from '../user-data-resource-manager';
+
+export class MockUsersResource extends UserDataResource {
+ name = 'mock-users';
+ accepts: SyncAccept[] = ['user'];
+ data = [];
+
+ async update(record: OriginRecord, resourcePks: PrimaryKey[]): Promise {
+ this.data[resourcePks[0]] = record.metaData;
+ return [];
+ }
+
+ async create(record: OriginRecord, matchKey: string): Promise {
+ this.data.push(record.metaData);
+ return [{ resourcesPk: this.data.length - 1, isDeleted: false }];
+ }
+}
+
+export class ErrorResource extends UserDataResource {
+ async update(record: OriginRecord, resourcePks: PrimaryKey[]): Promise {
+ return [];
+ }
+ async create(record: OriginRecord, matchKey: string): Promise {
+ return [];
+ }
+}
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/__tests__/resource-manager.test.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/__tests__/resource-manager.test.ts
new file mode 100644
index 0000000000..8319865b93
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/__tests__/resource-manager.test.ts
@@ -0,0 +1,153 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { MockDatabase, MockServer, createMockServer } from '@nocobase/test';
+import { UserDataResourceManager } from '../user-data-resource-manager';
+import { ErrorResource, MockUsersResource } from './mock-resource';
+
+describe('user-data-resource-manager', () => {
+ let app: MockServer;
+ let db: MockDatabase;
+ let resourceManager: UserDataResourceManager;
+
+ beforeEach(async () => {
+ app = await createMockServer({
+ plugins: ['user-data-sync'],
+ });
+ db = app.db;
+ resourceManager = new UserDataResourceManager();
+ resourceManager.db = db;
+ });
+
+ afterEach(async () => {
+ await db.clean({ drop: true });
+ await app.destroy();
+ });
+
+ it('register resource error', async () => {
+ try {
+ const errResource = new ErrorResource(db, app.logger);
+ expect(resourceManager.registerResource(errResource)).toThrowError(
+ '"name" for user data synchronize resource is required',
+ );
+ const errResource2 = new ErrorResource(db, app.logger);
+ errResource2.name = 'error';
+ expect(resourceManager.registerResource(errResource2)).toThrowError(
+ '"accepts" for user data synchronize resource is required',
+ );
+ } catch (error) {
+ // ...
+ }
+ });
+
+ it('register resource in order', async () => {
+ const usersResource = new MockUsersResource(db, app.logger);
+ resourceManager.registerResource(usersResource, { after: 'mock-users2' });
+ const usersResource2 = new MockUsersResource(db, app.logger);
+ usersResource2.name = 'mock-users2';
+ resourceManager.registerResource(usersResource2);
+ const nodes = resourceManager.resources.nodes;
+ expect(nodes.length).toBe(2);
+ expect(nodes).toEqual([usersResource2, usersResource]);
+ });
+
+ it('create for a resource', async () => {
+ const mockUsersResource = new MockUsersResource(db, app.logger);
+ resourceManager.registerResource(mockUsersResource);
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'user',
+ records: [
+ {
+ uid: '1',
+ nickname: 'test',
+ },
+ ],
+ matchKey: 'uid',
+ });
+ expect(mockUsersResource.data.length).toBe(1);
+ expect(mockUsersResource.data[0]).toMatchObject({
+ uid: '1',
+ nickname: 'test',
+ });
+ const originRecords = await resourceManager.findOriginRecords({
+ sourceName: 'test',
+ dataType: 'user',
+ sourceUks: ['1'],
+ });
+ expect(originRecords.length).toBe(1);
+ expect(originRecords[0]).toMatchObject({
+ sourceName: 'test',
+ dataType: 'user',
+ sourceUk: '1',
+ metaData: {
+ uid: '1',
+ nickname: 'test',
+ },
+ resources: [
+ {
+ resource: 'mock-users',
+ resourcePk: '0',
+ },
+ ],
+ });
+ });
+
+ it('update for a resource', async () => {
+ const mockUsersResource = new MockUsersResource(db, app.logger);
+ resourceManager.registerResource(mockUsersResource);
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'user',
+ records: [
+ {
+ uid: '1',
+ nickname: 'test',
+ },
+ ],
+ });
+ expect(mockUsersResource.data.length).toBe(1);
+ expect(mockUsersResource.data[0]).toMatchObject({
+ nickname: 'test',
+ });
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'user',
+ records: [
+ {
+ uid: '1',
+ nickname: 'test2',
+ },
+ ],
+ });
+ expect(mockUsersResource.data.length).toBe(1);
+ expect(mockUsersResource.data[0]).toMatchObject({
+ nickname: 'test2',
+ });
+ const originRecords = await resourceManager.findOriginRecords({
+ sourceName: 'test',
+ dataType: 'user',
+ sourceUks: ['1'],
+ });
+ expect(originRecords.length).toBe(1);
+ expect(originRecords[0]).toMatchObject({
+ sourceName: 'test',
+ dataType: 'user',
+ sourceUk: '1',
+ metaData: {
+ uid: '1',
+ nickname: 'test2',
+ },
+ lastMetaData: {
+ uid: '1',
+ nickname: 'test',
+ },
+ });
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/actions/user-data.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/actions/user-data.ts
new file mode 100644
index 0000000000..6aa15f9326
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/actions/user-data.ts
@@ -0,0 +1,44 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { Context, Next } from '@nocobase/actions';
+import { PluginUserDataSyncServer } from '../plugin';
+
+export default {
+ listSyncTypes: async (ctx: Context, next: Next) => {
+ const plugin = ctx.app.pm.get(PluginUserDataSyncServer) as PluginUserDataSyncServer;
+ ctx.body = plugin.sourceManager.listTypes();
+ await next();
+ },
+ pull: async (ctx: Context, next: Next) => {
+ const { name } = ctx.action.params;
+ const plugin = ctx.app.pm.get(PluginUserDataSyncServer) as PluginUserDataSyncServer;
+ await plugin.syncService.pull(name, ctx);
+ await next();
+ },
+ push: async (ctx: Context, next: Next) => {
+ const data = ctx.action.params.values || {};
+ const plugin = ctx.app.pm.get(PluginUserDataSyncServer) as PluginUserDataSyncServer;
+ try {
+ const result = await plugin.syncService.push(data);
+ ctx.body = { code: 0, message: 'success', result };
+ } catch (error) {
+ ctx.status = 500;
+ ctx.body = { code: 500, message: error.message };
+ return;
+ }
+ await next();
+ },
+ retry: async (ctx: Context, next: Next) => {
+ const { sourceId, id } = ctx.action.params;
+ const plugin = ctx.app.pm.get(PluginUserDataSyncServer) as PluginUserDataSyncServer;
+ await plugin.syncService.retry(sourceId, id, ctx);
+ await next();
+ },
+};
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/.gitkeep b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/user-data-sync-records-resources.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/user-data-sync-records-resources.ts
new file mode 100644
index 0000000000..b104bebd58
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/user-data-sync-records-resources.ts
@@ -0,0 +1,33 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { defineCollection } from '@nocobase/database';
+
+export default defineCollection({
+ name: 'userDataSyncRecordsResources',
+ fields: [
+ {
+ name: 'recordId',
+ type: 'bigInt',
+ interface: 'id',
+ },
+ {
+ name: 'resource',
+ interface: 'Select',
+ type: 'string',
+ allowNull: false,
+ },
+ {
+ name: 'resourcePk',
+ interface: 'Input',
+ type: 'string',
+ allowNull: true,
+ },
+ ],
+});
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/user-data-sync-records.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/user-data-sync-records.ts
new file mode 100644
index 0000000000..54e5c90bb8
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/user-data-sync-records.ts
@@ -0,0 +1,64 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { defineCollection } from '@nocobase/database';
+
+export default defineCollection({
+ dumpRules: {
+ group: 'third-party',
+ },
+ shared: true,
+ name: 'userDataSyncRecords',
+ createdAt: true,
+ updatedAt: true,
+ logging: true,
+ fields: [
+ {
+ name: 'id',
+ type: 'bigInt',
+ autoIncrement: true,
+ primaryKey: true,
+ allowNull: false,
+ interface: 'id',
+ },
+ {
+ name: 'sourceName',
+ interface: 'Input',
+ type: 'string',
+ allowNull: false,
+ },
+ {
+ name: 'sourceUk',
+ interface: 'Input',
+ type: 'string',
+ allowNull: false,
+ },
+ {
+ name: 'dataType',
+ interface: 'Select',
+ type: 'string',
+ allowNull: false,
+ },
+ {
+ name: 'resources',
+ type: 'hasMany',
+ target: 'userDataSyncRecordsResources',
+ sourceKey: 'id',
+ foreignKey: 'recordId',
+ },
+ {
+ type: 'json',
+ name: 'metaData',
+ },
+ {
+ type: 'json',
+ name: 'lastMetaData',
+ },
+ ],
+});
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/user-data-sync-sources.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/user-data-sync-sources.ts
new file mode 100644
index 0000000000..cedaaceadb
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/user-data-sync-sources.ts
@@ -0,0 +1,88 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { defineCollection } from '@nocobase/database';
+
+export default defineCollection({
+ dumpRules: {
+ group: 'third-party',
+ },
+ shared: true,
+ name: 'userDataSyncSources',
+ title: '{{t("Sync Sources")}}',
+ sortable: true,
+ model: 'SyncSourceModel',
+ createdBy: true,
+ updatedBy: true,
+ logging: true,
+ fields: [
+ {
+ name: 'id',
+ type: 'bigInt',
+ autoIncrement: true,
+ primaryKey: true,
+ allowNull: false,
+ interface: 'id',
+ },
+ {
+ interface: 'input',
+ type: 'string',
+ name: 'name',
+ allowNull: false,
+ unique: true,
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Source name")}}',
+ 'x-component': 'Input',
+ required: true,
+ },
+ },
+ {
+ interface: 'input',
+ type: 'string',
+ name: 'sourceType',
+ allowNull: false,
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Source Type")}}',
+ 'x-component': 'Input',
+ required: true,
+ },
+ },
+ {
+ interface: 'input',
+ type: 'string',
+ name: 'displayName',
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Source display name")}}',
+ 'x-component': 'Input',
+ },
+ translation: true,
+ },
+ {
+ type: 'boolean',
+ name: 'enabled',
+ defaultValue: false,
+ },
+ {
+ type: 'json',
+ name: 'options',
+ allowNull: false,
+ defaultValue: {},
+ },
+ {
+ type: 'hasMany',
+ name: 'tasks',
+ target: 'userDataSyncTasks',
+ sourceKey: 'id',
+ foreignKey: 'sourceId',
+ },
+ ],
+});
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/user-data-sync-tasks.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/user-data-sync-tasks.ts
new file mode 100644
index 0000000000..95d4d5de5f
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/collections/user-data-sync-tasks.ts
@@ -0,0 +1,110 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { defineCollection } from '@nocobase/database';
+
+export default defineCollection({
+ dumpRules: {
+ group: 'third-party',
+ },
+ name: 'userDataSyncTasks',
+ title: '{{t("Sync Tasks")}}',
+ sortable: 'sort',
+ model: 'SyncTaskModel',
+ createdBy: true,
+ updatedBy: true,
+ createdAt: true,
+ updatedAt: true,
+ logging: true,
+ shared: true,
+ fields: [
+ {
+ name: 'id',
+ type: 'bigInt',
+ autoIncrement: true,
+ primaryKey: true,
+ allowNull: false,
+ interface: 'id',
+ },
+ {
+ name: 'batch',
+ interface: 'input',
+ type: 'string',
+ allowNull: false,
+ unique: true,
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Batch")}}',
+ 'x-component': 'Input',
+ required: true,
+ },
+ },
+ {
+ name: 'source',
+ interface: 'input',
+ type: 'belongsTo',
+ target: 'userDataSyncSources',
+ targetKey: 'id',
+ foreignKey: 'sourceId',
+ allowNull: false,
+ uiSchema: {
+ type: 'object',
+ title: '{{t("Source")}}',
+ 'x-component': 'AssociationField',
+ 'x-component-props': {
+ fieldNames: {
+ value: 'id',
+ label: 'name',
+ },
+ },
+ required: true,
+ 'x-read-pretty': true,
+ },
+ },
+ {
+ name: 'status',
+ interface: 'Select',
+ type: 'string',
+ allowNull: false,
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Status")}}',
+ 'x-component': 'Select',
+ required: true,
+ },
+ },
+ {
+ name: 'message',
+ interface: 'input',
+ type: 'string',
+ allowNull: true,
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Message")}}',
+ 'x-component': 'Input',
+ required: false,
+ },
+ },
+ {
+ name: 'cost',
+ interface: 'input',
+ type: 'integer',
+ allowNull: true,
+ uiSchema: {
+ type: 'integer',
+ title: '{{t("Cost")}}',
+ 'x-component': 'InputNumber',
+ 'x-component-props': {
+ precision: 0,
+ },
+ required: false,
+ },
+ },
+ ],
+});
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/index.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/index.ts
new file mode 100644
index 0000000000..0384832b85
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/index.ts
@@ -0,0 +1,12 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+export { SyncSource } from './sync-source';
+export * from './user-data-resource-manager';
+export { default } from './plugin';
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/models/sync-source.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/models/sync-source.ts
new file mode 100644
index 0000000000..647d28570d
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/models/sync-source.ts
@@ -0,0 +1,17 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { Model } from '@nocobase/database';
+
+export class SyncSourceModel extends Model {
+ declare id: number;
+ declare name: string;
+ declare sourceType: string;
+ declare options: any;
+}
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/plugin.ts
new file mode 100644
index 0000000000..1bc33301c4
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/plugin.ts
@@ -0,0 +1,71 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { Plugin } from '@nocobase/server';
+import { UserDataResourceManager } from './user-data-resource-manager';
+import { UserDataSyncService } from './user-data-sync-service';
+import userDataActions from './actions/user-data';
+import { SyncSourceManager } from './sync-source-manager';
+import { SyncSourceModel } from './models/sync-source';
+import { LoggerOptions, Logger } from '@nocobase/logger';
+
+export class PluginUserDataSyncServer extends Plugin {
+ sourceManager: SyncSourceManager;
+ resourceManager: UserDataResourceManager;
+ syncService: UserDataSyncService;
+
+ async afterAdd() {}
+
+ async beforeLoad() {
+ this.app.db.registerModels({ SyncSourceModel });
+ this.sourceManager = new SyncSourceManager();
+ this.resourceManager = new UserDataResourceManager();
+ }
+
+ getLogger(): Logger {
+ const logger = this.createLogger({
+ dirname: 'user-data-sync',
+ filename: '%DATE%.log',
+ format: 'json',
+ } as LoggerOptions);
+
+ return logger;
+ }
+
+ async load() {
+ const logger = this.getLogger();
+ this.resourceManager.db = this.app.db;
+ this.resourceManager.logger = this.app.logger;
+ this.syncService = new UserDataSyncService(this.resourceManager, this.sourceManager, logger);
+ this.app.resourceManager.define({
+ name: 'userData',
+ actions: {
+ listSyncTypes: userDataActions.listSyncTypes,
+ pull: userDataActions.pull,
+ push: userDataActions.push,
+ retry: userDataActions.retry,
+ },
+ });
+
+ this.app.acl.registerSnippet({
+ name: `pm.${this.name}`,
+ actions: ['userData:*', 'userDataSyncSources:*'],
+ });
+ }
+
+ async install() {}
+
+ async afterEnable() {}
+
+ async afterDisable() {}
+
+ async remove() {}
+}
+
+export default PluginUserDataSyncServer;
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/sync-source-manager.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/sync-source-manager.ts
new file mode 100644
index 0000000000..c27525cfb9
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/sync-source-manager.ts
@@ -0,0 +1,58 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { Registry } from '@nocobase/utils';
+import { SyncSource, SyncSourceExtend } from './sync-source';
+import { Context } from '@nocobase/actions';
+import { SyncSourceModel } from './models/sync-source';
+
+type SyncSourceConfig = {
+ syncSource: SyncSourceExtend;
+ title?: string;
+};
+
+export class SyncSourceManager {
+ protected syncSourceTypes: Registry = new Registry();
+ registerType(syncSourceType: string, syncSourceConfig: SyncSourceConfig) {
+ this.syncSourceTypes.register(syncSourceType, syncSourceConfig);
+ }
+
+ listTypes() {
+ return Array.from(this.syncSourceTypes.getEntities()).map(([syncSourceType, source]) => ({
+ name: syncSourceType,
+ title: source.title,
+ }));
+ }
+
+ async getByName(name: string, ctx: Context) {
+ const repo = ctx.db.getRepository('userDataSyncSources');
+ const sourceInstance: SyncSourceModel = await repo.findOne({ filter: { enabled: true, name: name } });
+ if (!sourceInstance) {
+ throw new Error(`SyncSource [${name}] is not found.`);
+ }
+ return this.create(sourceInstance, ctx);
+ }
+
+ async getById(id: number, ctx: Context) {
+ const repo = ctx.db.getRepository('userDataSyncSources');
+ const sourceInstance: SyncSourceModel = await repo.findOne({ filter: { enabled: true }, filterByTk: id });
+ if (!sourceInstance) {
+ throw new Error(`SyncSource [${id}] is not found.`);
+ }
+ return this.create(sourceInstance, ctx);
+ }
+
+ create(sourceInstance: SyncSourceModel, ctx: Context) {
+ const { syncSource } = this.syncSourceTypes.get(sourceInstance.sourceType) || {};
+ if (!syncSource) {
+ throw new Error(`SyncSourceType [${sourceInstance.sourceType}] is not found.`);
+ }
+ return new syncSource({ sourceInstance: sourceInstance, options: sourceInstance.options, ctx });
+ }
+}
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/sync-source.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/sync-source.ts
new file mode 100644
index 0000000000..0be03bb65a
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/sync-source.ts
@@ -0,0 +1,106 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { Context } from '@nocobase/actions';
+import { SyncSourceModel } from './models/sync-source';
+import { UserData } from './user-data-resource-manager';
+import dayjs from 'dayjs';
+
+export type SyncSourceConfig = {
+ sourceInstance: SyncSourceModel;
+ options: {
+ [key: string]: any;
+ };
+ ctx: Context;
+};
+
+interface ISyncSource {
+ pull(): Promise;
+}
+
+export abstract class SyncSource implements ISyncSource {
+ instance: SyncSourceModel;
+ protected options: {
+ [key: string]: any;
+ };
+ protected ctx: Context;
+
+ constructor(config: SyncSourceConfig) {
+ const { options, ctx, sourceInstance } = config;
+ this.instance = sourceInstance;
+ this.options = options;
+ this.ctx = ctx;
+ }
+
+ abstract pull(): Promise;
+
+ async newTask() {
+ const batch = generateUniqueNumber();
+ return await this.instance.createTask({ batch, status: 'init' });
+ }
+
+ async beginTask(taskId: number) {
+ const tasks = await this.instance.getTasks({ where: { id: taskId } });
+ if (!tasks && !tasks.length) {
+ throw new Error(`Task [${taskId}] is not found.`);
+ }
+ const task = tasks[0];
+ if (task.status !== 'init') {
+ throw new Error(`Task [${taskId}] is not init.`);
+ }
+ task.status = 'processing';
+ await task.save();
+ }
+
+ async endTask(params: EndTaskParams) {
+ const { taskId, success, cost, message } = params;
+ const tasks = await this.instance.getTasks({ where: { id: taskId } });
+ if (!tasks && !tasks.length) {
+ throw new Error(`Task [${taskId}] is not found.`);
+ }
+ const task = tasks[0];
+ if (task.status !== 'processing') {
+ throw new Error(`Task [${taskId}] is not processing.`);
+ }
+ task.status = success ? 'success' : 'failed';
+ task.cost = cost;
+ task.message = message;
+ await task.save();
+ }
+
+ async retryTask(taskId: number) {
+ const tasks = await this.instance.getTasks({ where: { id: taskId } });
+ if (!tasks && !tasks.length) {
+ throw new Error(`Task [${taskId}] is not found.`);
+ }
+ const task = tasks[0];
+ if (task.status !== 'failed') {
+ throw new Error(`Task [${taskId}] is not failed.`);
+ }
+ task.status = 'processing';
+ task.message = '';
+ await task.save();
+ return task;
+ }
+}
+
+export type SyncSourceExtend = new (config: SyncSourceConfig) => T;
+
+type EndTaskParams = {
+ taskId: number;
+ success: boolean;
+ cost?: number;
+ message?: string;
+};
+
+function generateUniqueNumber() {
+ const formattedDate = dayjs().format('YYYYMMDDHHmmss');
+ const randomDigits = Math.floor(100000 + Math.random() * 900000);
+ return formattedDate + randomDigits;
+}
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/user-data-resource-manager.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/user-data-resource-manager.ts
new file mode 100644
index 0000000000..fa0aac086e
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/user-data-resource-manager.ts
@@ -0,0 +1,268 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { Toposort, ToposortOptions } from '@nocobase/utils';
+import Database, { Repository } from '@nocobase/database';
+import { SystemLogger } from '@nocobase/logger';
+
+export type FormatUser = {
+ uid: string;
+ username?: string;
+ email?: string;
+ nickname?: string;
+ phone?: string;
+ departments?: string[];
+ isDeleted?: boolean;
+ [key: string]: any;
+};
+
+export type FormatDepartment = {
+ uid: string;
+ title?: string;
+ parentUid?: string;
+ isDeleted?: boolean;
+ [key: string]: any;
+};
+
+export type UserDataRecord = FormatUser | FormatDepartment;
+
+export type SyncDataType = 'user' | 'department';
+
+export type SyncAccept = SyncDataType;
+
+export type OriginRecord = {
+ id: number;
+ sourceName: string;
+ sourceUk: string;
+ dataType: SyncDataType;
+ metaData: UserDataRecord;
+ resources: {
+ resource: string;
+ resourcePk: string;
+ }[];
+};
+
+export type UserData = {
+ dataType: SyncDataType;
+ matchKey?: string;
+ records: UserDataRecord[];
+ sourceName: string;
+};
+
+export type PrimaryKey = number | string;
+
+export type RecordResourceChanged = {
+ resourcesPk: PrimaryKey;
+ isDeleted: boolean;
+};
+
+export abstract class UserDataResource {
+ name: string;
+ accepts: SyncAccept[];
+ db: Database;
+ logger: SystemLogger;
+
+ constructor(db: Database, logger: SystemLogger) {
+ this.db = db;
+ this.logger = logger;
+ }
+
+ abstract update(record: OriginRecord, resourcePks: PrimaryKey[], matchKey?: string): Promise;
+ abstract create(record: OriginRecord, matchKey: string): Promise;
+
+ get syncRecordRepo() {
+ return this.db.getRepository('userDataSyncRecords');
+ }
+
+ get syncRecordResourceRepo() {
+ return this.db.getRepository('userDataSyncRecordsResources');
+ }
+}
+
+export type SyncResult = {
+ resource: string;
+ detail: {
+ count: {
+ all: number;
+ success: number;
+ failed: number;
+ };
+ failedRecords: {
+ record: UserDataRecord;
+ message: string;
+ }[];
+ };
+};
+
+export class UserDataResourceManager {
+ resources = new Toposort();
+ syncRecordRepo: Repository;
+ syncRecordResourceRepo: Repository;
+ logger: SystemLogger;
+
+ registerResource(resource: UserDataResource, options?: ToposortOptions) {
+ if (!resource.name) {
+ throw new Error('"name" for user data synchronize resource is required');
+ }
+ if (!resource.accepts) {
+ throw new Error('"accepts" for user data synchronize resource is required');
+ }
+ this.resources.add(resource, { tag: resource.name, ...options });
+ }
+
+ set db(value: Database) {
+ this.syncRecordRepo = value.getRepository('userDataSyncRecords');
+ this.syncRecordResourceRepo = value.getRepository('userDataSyncRecordsResources');
+ }
+
+ async saveOriginRecords(data: UserData): Promise {
+ for (const record of data.records) {
+ if (record.uid === undefined) {
+ throw new Error(`record must has uid, error record: ${JSON.stringify(record)}`);
+ }
+ const syncRecord = await this.syncRecordRepo.findOne({
+ where: {
+ sourceName: data.sourceName,
+ sourceUk: record.uid,
+ dataType: data.dataType,
+ },
+ });
+ if (syncRecord) {
+ syncRecord.lastMetaData = syncRecord.metaData;
+ syncRecord.metaData = record;
+ await syncRecord.save();
+ } else {
+ await this.syncRecordRepo.create({
+ values: {
+ sourceName: data.sourceName,
+ sourceUk: record.uid,
+ dataType: data.dataType,
+ metaData: record,
+ },
+ });
+ }
+ }
+ }
+
+ async findOriginRecords({ sourceName, dataType, sourceUks }): Promise {
+ return await this.syncRecordRepo.find({
+ appends: ['resources'],
+ filter: { sourceName, dataType, sourceUk: { $in: sourceUks } },
+ });
+ }
+
+ async addResourceToOriginRecord({ recordId, resource, resourcePk }): Promise {
+ const syncRecord = await this.syncRecordRepo.findOne({
+ filter: {
+ id: recordId,
+ },
+ });
+ if (syncRecord) {
+ await syncRecord.createResource({
+ resource,
+ resourcePk,
+ });
+ }
+ }
+
+ async removeResourceFromOriginRecord({ recordId, resource, resourcePk }): Promise {
+ const recordResource = await this.syncRecordResourceRepo.findOne({
+ where: {
+ recordId,
+ resource,
+ resourcePk,
+ },
+ });
+ if (recordResource) {
+ await recordResource.destroy();
+ }
+ }
+
+ async updateOrCreate(data: UserData): Promise {
+ await this.saveOriginRecords(data);
+ const { dataType, sourceName, records, matchKey } = data;
+ const sourceUks = records.map((record) => record.uid);
+ let processed = false;
+ const syncResults: SyncResult[] = [];
+ for (const resource of this.resources.nodes) {
+ if (!resource.accepts.includes(dataType)) {
+ continue;
+ }
+ const associateResource = resource.name;
+ processed = true;
+ const originRecords = await this.findOriginRecords({ sourceName, sourceUks, dataType });
+ if (!(originRecords && originRecords.length)) {
+ continue;
+ }
+ const successRecords = [];
+ const failedRecords = [];
+ for (const originRecord of originRecords) {
+ const resourceRecords = originRecord.resources?.filter(
+ (r: { resource: string }) => r.resource === associateResource,
+ );
+ let recordResourceChangeds: RecordResourceChanged[];
+ if (resourceRecords && resourceRecords.length > 0) {
+ const resourcePks = resourceRecords.map((r: { resourcePk: string }) => r.resourcePk);
+ try {
+ recordResourceChangeds = await resource.update(originRecord, resourcePks, matchKey);
+ this.logger?.debug(`update record success. Data changed: ${JSON.stringify(recordResourceChangeds)}`);
+ successRecords.push(originRecord.metaData);
+ } catch (error) {
+ this.logger?.warn(`update record error: ${error.message}`, { originRecord });
+ failedRecords.push({ record: originRecord.metaData, message: error.message });
+ continue;
+ }
+ } else {
+ try {
+ recordResourceChangeds = await resource.create(originRecord, matchKey);
+ this.logger?.debug(`create record success. Data changed: ${JSON.stringify(recordResourceChangeds)}`);
+ successRecords.push(originRecord.metaData);
+ } catch (error) {
+ this.logger?.warn(`create record error: ${error.message}`, { originRecord });
+ failedRecords.push({ record: originRecord.metaData, message: error.message });
+ continue;
+ }
+ }
+ if (!recordResourceChangeds || recordResourceChangeds.length === 0) {
+ continue;
+ }
+ for (const { resourcesPk, isDeleted } of recordResourceChangeds) {
+ if (isDeleted) {
+ await this.removeResourceFromOriginRecord({
+ recordId: originRecord.id,
+ resource: associateResource,
+ resourcePk: resourcesPk,
+ });
+ } else {
+ await this.addResourceToOriginRecord({
+ recordId: originRecord.id,
+ resource: associateResource,
+ resourcePk: resourcesPk,
+ });
+ }
+ }
+ }
+ syncResults.push({
+ resource: associateResource,
+ detail: {
+ count: {
+ all: originRecords.length,
+ success: successRecords.length,
+ failed: failedRecords.length,
+ },
+ failedRecords,
+ },
+ });
+ }
+ if (!processed) {
+ throw new Error(`dataType "${dataType}" is not support`);
+ }
+ return syncResults;
+ }
+}
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/server/user-data-sync-service.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/user-data-sync-service.ts
new file mode 100644
index 0000000000..76bd25f69b
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/server/user-data-sync-service.ts
@@ -0,0 +1,111 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { SyncResult, UserData, UserDataResourceManager } from './user-data-resource-manager';
+import { SyncSourceManager } from './sync-source-manager';
+import { Context } from '@nocobase/actions';
+import { SyncSource } from './sync-source';
+import { Logger } from '@nocobase/logger';
+
+export class UserDataSyncService {
+ resourceManager: UserDataResourceManager;
+ sourceManager: SyncSourceManager;
+ logger: Logger;
+
+ constructor(resourceManager: UserDataResourceManager, sourceManager: SyncSourceManager, logger: Logger) {
+ this.resourceManager = resourceManager;
+ this.sourceManager = sourceManager;
+ this.logger = logger;
+ }
+
+ async pull(sourceName: string, ctx: Context) {
+ const source = await this.sourceManager.getByName(sourceName, ctx);
+ const task = await source.newTask();
+ await source.beginTask(task.id);
+ ctx.log.info('begin sync task of source', { source: sourceName, sourceType: source.instance.sourceType });
+ this.runSync(source, task, ctx);
+ }
+
+ async push(data: any): Promise {
+ const { dataType, records } = data;
+ if (dataType === undefined) {
+ throw new Error('dataType for user data synchronize is required');
+ }
+ if (dataType !== 'user' && dataType !== 'department') {
+ throw new Error('dataType must be user or department');
+ }
+ if (records === undefined) {
+ throw new Error('records for user data synchronize is required');
+ }
+ if (records.length === 0) {
+ throw new Error('records must have at least one piece of data');
+ }
+ const userData: UserData = {
+ dataType: data.dataType,
+ matchKey: data.matchKey,
+ records: data.records,
+ sourceName: data.sourceName ? data.sourceName : 'api',
+ };
+ this.logger.info({
+ source: data.sourceName ? data.sourceName : 'api',
+ sourceType: 'api',
+ data: data,
+ });
+ return await this.resourceManager.updateOrCreate(userData);
+ }
+
+ async retry(sourceId: number, taskId: number, ctx: Context) {
+ const source = await this.sourceManager.getById(sourceId, ctx);
+ const task = await source.retryTask(taskId);
+ ctx.log.info('retry sync task of source', {
+ source: source.instance.name,
+ sourceType: source.instance.name,
+ task: task.id,
+ });
+ this.runSync(source, task, ctx);
+ }
+
+ async runSync(source: SyncSource, task: any, ctx: Context) {
+ const currentTimeMillis = new Date().getTime();
+ try {
+ ctx.log.info('begin pull data of source', {
+ source: source.instance.name,
+ sourceType: source.instance.sourceType,
+ });
+ const data = await source.pull();
+ // 输出拉取的数据
+ this.logger.info({
+ source: source.instance.name,
+ sourceType: source.instance.sourceType,
+ batch: task.batch,
+ data: data,
+ });
+ ctx.log.info('end pull data of source', { source: source.instance.name, sourceType: source.instance.sourceType });
+ ctx.log.info('begin update data of source', {
+ source: source.instance.name,
+ sourceType: source.instance.sourceType,
+ });
+ for (const item of data) {
+ await this.resourceManager.updateOrCreate(item);
+ }
+ ctx.log.info('end update data of source', {
+ source: source.instance.name,
+ sourceType: source.instance.sourceType,
+ });
+ const costTime = new Date().getTime() - currentTimeMillis;
+ await source.endTask({ taskId: task.id, success: true, cost: costTime });
+ } catch (err) {
+ ctx.log.error(
+ `sync task of source: ${source.instance.name} sourceType: ${source.instance.sourceType} error: ${err.message}`,
+ { method: 'runSync', err: err.stack, cause: err.cause },
+ );
+ await source.endTask({ taskId: task.id, success: false, message: err.message });
+ }
+ }
+}
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/src/swagger/index.ts b/packages/plugins/@nocobase/plugin-user-data-sync/src/swagger/index.ts
new file mode 100644
index 0000000000..6cb7f81a0d
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/src/swagger/index.ts
@@ -0,0 +1,121 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+export default {
+ info: {
+ title: 'NocoBase API - User data synchronization plugin',
+ },
+ paths: {
+ '/userData:push': {
+ post: {
+ description: 'Push user data',
+ tags: ['Push'],
+ security: [],
+ requestBody: {
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: {
+ $ref: '#/components/schemas/userData',
+ },
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'ok',
+ },
+ },
+ },
+ },
+ },
+ components: {
+ schemas: {
+ userData: {
+ type: 'object',
+ description: '用户数据',
+ properties: {
+ dataType: {
+ type: 'string',
+ description: '数据类型, 目前可选值为: user, department',
+ },
+ uniqueKey: {
+ type: 'string',
+ description: '唯一键',
+ },
+ records: {
+ type: 'array',
+ description:
+ '数据, 若 dataType 为 user, 则为用户数据字段见schemas/user, 若 dataType 为 department, 则为部门数据字段见schemas/department',
+ items: {
+ type: 'object',
+ },
+ },
+ sourceName: {
+ type: 'string',
+ description: '数据源名称',
+ },
+ },
+ },
+ user: {
+ type: 'object',
+ description: '用户',
+ properties: {
+ id: {
+ type: 'integer',
+ description: 'ID',
+ },
+ nickname: {
+ type: 'string',
+ description: '昵称',
+ },
+ email: {
+ type: 'string',
+ description: '邮箱',
+ },
+ phone: {
+ type: 'string',
+ description: '手机号',
+ },
+ departments: {
+ type: 'array',
+ description: '所属部门, 部门ID 数组',
+ items: {
+ type: 'string',
+ },
+ },
+ },
+ },
+ department: {
+ type: 'object',
+ description: '部门',
+ properties: {
+ id: {
+ type: 'string',
+ description: 'ID',
+ },
+ name: {
+ type: 'string',
+ description: '名称',
+ },
+ parentId: {
+ type: 'string',
+ description: '父级部门ID',
+ },
+ },
+ },
+ },
+ },
+};
+
+/*
+/api/userData:push
+*/
diff --git a/packages/plugins/@nocobase/plugin-users/package.json b/packages/plugins/@nocobase/plugin-users/package.json
index de694f975f..d1163f130a 100644
--- a/packages/plugins/@nocobase/plugin-users/package.json
+++ b/packages/plugins/@nocobase/plugin-users/package.json
@@ -19,6 +19,7 @@
"@nocobase/database": "1.x",
"@nocobase/plugin-acl": "1.x",
"@nocobase/plugin-auth": "1.x",
+ "@nocobase/plugin-user-data-sync": "1.x",
"@nocobase/resourcer": "1.x",
"@nocobase/server": "1.x",
"@nocobase/test": "1.x",
diff --git a/packages/plugins/@nocobase/plugin-users/src/server/__tests__/data-sync.test.ts b/packages/plugins/@nocobase/plugin-users/src/server/__tests__/data-sync.test.ts
new file mode 100644
index 0000000000..816925a7dd
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-users/src/server/__tests__/data-sync.test.ts
@@ -0,0 +1,122 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { UserDataResourceManager } from '@nocobase/plugin-user-data-sync';
+import { MockDatabase, MockServer, createMockServer } from '@nocobase/test';
+import PluginUserDataSyncServer from 'packages/plugins/@nocobase/plugin-user-data-sync/src/server/plugin';
+
+describe('user data sync', () => {
+ let app: MockServer;
+ let db: MockDatabase;
+ let resourceManager: UserDataResourceManager;
+
+ beforeEach(async () => {
+ app = await createMockServer({
+ plugins: ['user-data-sync', 'users'],
+ });
+ db = app.db;
+ const plugin = app.pm.get('user-data-sync') as PluginUserDataSyncServer;
+ resourceManager = plugin.resourceManager;
+ });
+
+ afterEach(async () => {
+ await db.clean({ drop: true });
+ await app.destroy();
+ });
+
+ it('should create user', async () => {
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'user',
+ matchKey: 'email',
+ records: [
+ {
+ uid: '1',
+ nickname: 'test',
+ email: 'test@nocobase.com',
+ },
+ ],
+ });
+ const user = await db.getRepository('users').findOne({
+ filter: {
+ email: 'test@nocobase.com',
+ },
+ });
+ expect(user).toBeTruthy();
+ expect(user.nickname).toBe('test');
+ });
+
+ it('should update existing user when creating', async () => {
+ const user = await db.getRepository('users').create({
+ values: {
+ email: 'test@nocobase.com',
+ },
+ });
+ expect(user.nickname).toBeFalsy();
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'user',
+ matchKey: 'email',
+ records: [
+ {
+ uid: '1',
+ nickname: 'test',
+ email: 'test@nocobase.com',
+ },
+ ],
+ });
+ const user2 = await db.getRepository('users').findOne({
+ filter: {
+ id: user.id,
+ },
+ });
+ expect(user2).toBeTruthy();
+ expect(user2.nickname).toBe('test');
+ });
+
+ it('shoud update user', async () => {
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'user',
+ matchKey: 'email',
+ records: [
+ {
+ uid: '1',
+ nickname: 'test',
+ email: 'test@nocobase.com',
+ },
+ ],
+ });
+ const user = await db.getRepository('users').findOne({
+ filter: {
+ email: 'test@nocobase.com',
+ },
+ });
+ expect(user).toBeTruthy();
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'user',
+ matchKey: 'email',
+ records: [
+ {
+ uid: '1',
+ nickname: 'test2',
+ email: 'test@nocobase.com',
+ },
+ ],
+ });
+ const user2 = await db.getRepository('users').findOne({
+ filter: {
+ id: user.id,
+ },
+ });
+ expect(user2).toBeTruthy();
+ expect(user2.nickname).toBe('test2');
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-users/src/server/server.ts b/packages/plugins/@nocobase/plugin-users/src/server/server.ts
index 54aa0f0c44..ac4d218e0b 100644
--- a/packages/plugins/@nocobase/plugin-users/src/server/server.ts
+++ b/packages/plugins/@nocobase/plugin-users/src/server/server.ts
@@ -11,10 +11,11 @@ import { Collection, Op } from '@nocobase/database';
import { Plugin } from '@nocobase/server';
import { parse } from '@nocobase/utils';
import { resolve } from 'path';
-
import { Cache } from '@nocobase/cache';
import * as actions from './actions/users';
import { UserModel } from './models/UserModel';
+import PluginUserDataSyncServer from '@nocobase/plugin-user-data-sync';
+import { UserDataSyncResource } from './user-data-sync-resource';
export default class PluginUsersServer extends Plugin {
async beforeLoad() {
@@ -179,6 +180,11 @@ export default class PluginUsersServer extends Plugin {
}
}
});
+
+ const userDataSyncPlugin = this.app.pm.get('user-data-sync') as PluginUserDataSyncServer;
+ if (userDataSyncPlugin) {
+ userDataSyncPlugin.resourceManager.registerResource(new UserDataSyncResource(this.db, this.app.logger));
+ }
}
getInstallingData(options: any = {}) {
diff --git a/packages/plugins/@nocobase/plugin-users/src/server/user-data-sync-resource.ts b/packages/plugins/@nocobase/plugin-users/src/server/user-data-sync-resource.ts
new file mode 100644
index 0000000000..ec9137059b
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-users/src/server/user-data-sync-resource.ts
@@ -0,0 +1,102 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { Model } from '@nocobase/database';
+import {
+ FormatUser,
+ OriginRecord,
+ PrimaryKey,
+ RecordResourceChanged,
+ SyncAccept,
+ UserDataResource,
+} from '@nocobase/plugin-user-data-sync';
+
+export class UserDataSyncResource extends UserDataResource {
+ name = 'users';
+ accepts: SyncAccept[] = ['user'];
+
+ get userRepo() {
+ return this.db.getRepository('users');
+ }
+
+ async updateUser(user: Model, sourceUser: FormatUser) {
+ if (sourceUser.isDeleted) {
+ // 删除用户
+ const roles = await user.getRoles();
+ // 是否有Root角色
+ for (const role of roles) {
+ if (role.name === 'root') {
+ return;
+ }
+ }
+ await user.destroy();
+ return;
+ }
+ let dataChanged = false;
+ if (sourceUser.username !== undefined && user.username !== sourceUser.username) {
+ user.username = sourceUser.username;
+ dataChanged = true;
+ }
+ if (sourceUser.phone !== undefined && user.phone !== sourceUser.phone) {
+ user.phone = sourceUser.phone;
+ dataChanged = true;
+ }
+ if (sourceUser.email !== undefined && user.email !== sourceUser.email) {
+ user.email = sourceUser.email;
+ dataChanged = true;
+ }
+ if (sourceUser.nickname !== undefined && user.nickname !== sourceUser.nickname) {
+ user.nickname = sourceUser.nickname;
+ dataChanged = true;
+ }
+ if (dataChanged) {
+ await user.save();
+ }
+ }
+
+ async update(record: OriginRecord, resourcePks: PrimaryKey[], matchKey: string): Promise {
+ const { metaData: sourceUser } = record;
+ const resourcePk = resourcePks[0];
+ const user = await this.userRepo.findOne({
+ filterByTk: resourcePk,
+ });
+ if (!user) {
+ // 用户不存在, 重新创建用户
+ const result = await this.create(record, matchKey);
+ return [...result, { resourcesPk: resourcePk, isDeleted: true }];
+ }
+ await this.updateUser(user, sourceUser);
+ return [];
+ }
+
+ async create(record: OriginRecord, matchKey: string): Promise {
+ const { metaData: sourceUser } = record;
+ const filter = {};
+ let user: any;
+ if (['phone', 'email', 'username'].includes(matchKey)) {
+ filter[matchKey] = sourceUser[matchKey];
+ user = await this.userRepo.findOne({
+ filter,
+ });
+ }
+ if (user) {
+ await this.updateUser(user, sourceUser);
+ } else {
+ user = await this.userRepo.create({
+ values: {
+ nickname: sourceUser.nickname,
+ phone: sourceUser.phone,
+ email: sourceUser.email,
+ username: sourceUser.username,
+ },
+ });
+ }
+ return [{ resourcesPk: user.id, isDeleted: false }];
+ }
+}
diff --git a/packages/presets/nocobase/package.json b/packages/presets/nocobase/package.json
index 5b89f1b465..abc22716df 100644
--- a/packages/presets/nocobase/package.json
+++ b/packages/presets/nocobase/package.json
@@ -52,6 +52,7 @@
"@nocobase/plugin-theme-editor": "1.4.0-alpha",
"@nocobase/plugin-ui-schema-storage": "1.4.0-alpha",
"@nocobase/plugin-users": "1.4.0-alpha",
+ "@nocobase/plugin-user-data-sync": "1.4.0-alpha",
"@nocobase/plugin-verification": "1.4.0-alpha",
"@nocobase/plugin-workflow": "1.4.0-alpha",
"@nocobase/plugin-workflow-action-trigger": "1.4.0-alpha",
diff --git a/packages/presets/nocobase/src/server/index.ts b/packages/presets/nocobase/src/server/index.ts
index 44d8a948ed..49e2c0aed9 100644
--- a/packages/presets/nocobase/src/server/index.ts
+++ b/packages/presets/nocobase/src/server/index.ts
@@ -22,6 +22,7 @@ export class PresetNocoBase extends Plugin {
'field-sequence',
'verification',
'users',
+ 'user-data-sync',
'acl',
'field-china-region',
'workflow',