feat: user data sync plugin (#4986)

* feat: user data sync plugin

* fix: nickname bug

* feat: adjust sync

* fix: delete package-lock.json

* feat: adjust push

* feat: adjust user-data-sync plugin

* fix: delete submodule

* fix: sync bug

* fix: adjust log

* fix: adjust log

* chore: add to build-in

* chore: update version

* chore: add api doc

* chore: add plugin description

* chore: api doc

* chore: change keywords

* feat: add no source type prompt

* chore: text to json

* feat: optimize resource manager

* fix: test & typo

* test: add tests for resource manager & fix bug

* fix: user sync

* chore: remove department resource

* fix: test

* fix: build

* fix: push data

* fix: push api add default sourceName

* test: add api test

* fix: uniqueKey support any

* chore: single user belongs to multi department situation

* chore: department

* fix: fix sync bugs

* fix: sync departments

* chore: remove department logic

* chore: remove department

* fix: fix logger type error

* fix: fix sync bug

* fix: logger & role

* fix: fix sync bugs

* fix: fix sync bug

* fix: fix sync bugs

* test: update test cases

* chore: update

* chore: update

* fix: test

* fix: test

* fix: test

* fix: bugs

* fix: version

---------

Co-authored-by: chenzhi <chenzhi@zhuopaikeji.com>
Co-authored-by: xilesun <2013xile@gmail.com>
This commit is contained in:
Zhi Chen 2024-08-27 05:25:30 +08:00 committed by GitHub
parent c552402480
commit 2c757c43c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2858 additions and 1 deletions

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-user-data-sync

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -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"
]
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -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 ? <Component /> : null;
},
{ displayName: 'Options' },
);

View File

@ -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: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<>
{t('No user data source plugin installed', { ns: NAMESPACE })}
<br />{' '}
<a
target="_blank"
href={
api.auth.locale === 'zh-CN'
? 'https://docs-cn.nocobase.com/handbook/user-data-sync'
: 'https://docs.nocobase.com/handbook/user-data-sync'
}
rel="noreferrer"
>
{t('View documentation', { ns: NAMESPACE })}
</a>
</>
}
/>
),
onClick: () => {},
},
];
return (
<ActionContextProvider value={{ visible, setVisible }}>
<SourceTypeContext.Provider value={{ type }}>
<Dropdown menu={{ items: items && items.length > 0 ? items : emptyItem }}>
<Button icon={<PlusOutlined />} type={'primary'}>
{t('Add new')} <DownOutlined />
</Button>
</Dropdown>
<SchemaComponent scope={{ types, setType, useCustomFormProps }} schema={createFormSchema} />
</SourceTypeContext.Provider>
</ActionContextProvider>
);
};
const Tasks = () => {
const { t } = useUserDataSyncSourceTranslation();
const [visible, setVisible] = useState(false);
return (
<ActionContextProvider value={{ visible, setVisible }}>
<Button
type={'link'}
onClick={() => {
setVisible(true);
}}
>
{t('Tasks')}
</Button>
<ExtendCollectionsProvider collections={[taskCollection]}>
<SchemaComponent scope={{ useRetryActionProps, useTasksTableBlockProps }} schema={tasksTableBlockSchema} />
</ExtendCollectionsProvider>
</ActionContextProvider>
);
};
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 (
<SourceTypesContext.Provider value={{ types }}>
<ExtendCollectionsProvider collections={[sourceCollection]}>
<SchemaComponent
schema={userDataSyncSourcesSchema}
components={{ AddNew, Options, Tasks }}
scope={{
types,
t,
useEditFormProps,
useSubmitActionProps,
useDeleteActionProps,
useSyncActionProps,
useValuesFromOptions,
}}
/>
</ExtendCollectionsProvider>
</SourceTypesContext.Provider>
);
};

View File

@ -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<WebAssembly.Instance>;
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;
}

View File

@ -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<SourceOptions>();
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;

View File

@ -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' });
}

View File

@ -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',
},
},
},
},
},
},
},
},
};

View File

@ -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;
};

View File

@ -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';

View File

@ -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"
}

View File

@ -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": "查看文档"
}

View File

@ -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',
});
});
});

View File

@ -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<RecordResourceChanged[]> {
this.data[resourcePks[0]] = record.metaData;
return [];
}
async create(record: OriginRecord, matchKey: string): Promise<RecordResourceChanged[]> {
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<RecordResourceChanged[]> {
return [];
}
async create(record: OriginRecord, matchKey: string): Promise<RecordResourceChanged[]> {
return [];
}
}

View File

@ -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',
},
});
});
});

View File

@ -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();
},
};

View File

@ -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,
},
],
});

View File

@ -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',
},
],
});

View File

@ -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',
},
],
});

View File

@ -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,
},
},
],
});

View File

@ -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';

View File

@ -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;
}

View File

@ -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;

View File

@ -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<SyncSource>;
title?: string;
};
export class SyncSourceManager {
protected syncSourceTypes: Registry<SyncSourceConfig> = 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 });
}
}

View File

@ -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<UserData[]>;
}
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<UserData[]>;
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<T extends SyncSource> = 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;
}

View File

@ -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<RecordResourceChanged[]>;
abstract create(record: OriginRecord, matchKey: string): Promise<RecordResourceChanged[]>;
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<UserDataResource>();
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<void> {
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<OriginRecord[]> {
return await this.syncRecordRepo.find({
appends: ['resources'],
filter: { sourceName, dataType, sourceUk: { $in: sourceUks } },
});
}
async addResourceToOriginRecord({ recordId, resource, resourcePk }): Promise<void> {
const syncRecord = await this.syncRecordRepo.findOne({
filter: {
id: recordId,
},
});
if (syncRecord) {
await syncRecord.createResource({
resource,
resourcePk,
});
}
}
async removeResourceFromOriginRecord({ recordId, resource, resourcePk }): Promise<void> {
const recordResource = await this.syncRecordResourceRepo.findOne({
where: {
recordId,
resource,
resourcePk,
},
});
if (recordResource) {
await recordResource.destroy();
}
}
async updateOrCreate(data: UserData): Promise<SyncResult[]> {
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;
}
}

View File

@ -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<SyncResult[]> {
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 });
}
}
}

View File

@ -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
*/

View File

@ -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",

View File

@ -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');
});
});

View File

@ -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 = {}) {

View File

@ -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<RecordResourceChanged[]> {
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<RecordResourceChanged[]> {
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 }];
}
}

View File

@ -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",

View File

@ -22,6 +22,7 @@ export class PresetNocoBase extends Plugin {
'field-sequence',
'verification',
'users',
'user-data-sync',
'acl',
'field-china-region',
'workflow',