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