Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
"dependencies": {
"@arshiash80/strapi-plugin-iconhub": "^1.1.1",
"@ckeditor/strapi-plugin-ckeditor": "^1.1.1",
"@strapi/plugin-cloud": "~5.23.5",
"@strapi/plugin-color-picker": "~5.23.5",
"@strapi/plugin-documentation": "~5.23.5",
"@strapi/plugin-users-permissions": "~5.23.5",
"@strapi/strapi": "~5.23.5",
"@strapi/plugin-cloud": "~5.23.6",
"@strapi/plugin-color-picker": "~5.23.6",
"@strapi/plugin-documentation": "~5.23.6",
"@strapi/plugin-users-permissions": "~5.23.6",
"@strapi/strapi": "~5.23.6",
"better-sqlite3": "^12.2.0",
"cross-env": "^10.0.0",
"mobx-restful-migrator": "^0.1.0",
"pg": "^8.16.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand All @@ -33,14 +34,15 @@
"husky": "^9.1.7",
"koajax": "^3.1.2",
"lint-staged": "^16.1.6",
"mobx-restful": "^2.1.3",
"mobx-strapi": "^0.8.1",
"prettier": "^3.6.2",
"swagger-typescript-api": "^13.2.11",
"swagger-typescript-api": "^13.2.13",
"tsx": "^4.20.5",
"typescript": "~5.9.2",
"web-utility": "^4.6.1",
"xlsx": "^0.18.5",
"zx": "^8.8.1"
"zx": "^8.8.2"
},
"pnpm": {
"onlyBuiltDependencies": [
Expand Down
734 changes: 359 additions & 375 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

168 changes: 61 additions & 107 deletions scripts/import-data.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,47 @@
#!/usr/bin/env tsx

/**
* Strapi database import script (Refactored)
* Strapi database import script using MobX-RESTful-migrator
* Support import NGO organization data from Excel file to Strapi database
*/

import * as fs from 'node:fs';
import * as path from 'node:path';
import { Config, OrganizationData, ExtendedUserData } from './types';
import { RestMigrator } from 'mobx-restful-migrator';

// Create WeakMap to store user data for organizations
const userWeakMap = new WeakMap<OrganizationData, ExtendedUserData>();

// Import refactored modules
import { DataTransformer } from './transformers/data-transformer';
import { UserTransformer } from './transformers/user-transformer';
import { migrationMapping } from './transformers/data-transformer';
import { Config } from './types';
import { ExcelReader } from './utils/excel-reader';
import { StrapiAPI } from './utils/strapi-api';
import { DataImporter } from './utils/data-importer';
import { ImportLogger } from './utils/import-logger';
import { TargetOrganizationModel } from './utils/strapi-api';

// Configuration
const CONFIG: Config = {
STRAPI_URL: process.env.STRAPI_URL || 'http://localhost:1337',
STRAPI_TOKEN: process.env.STRAPI_TOKEN || '',
EXCEL_FILE: process.env.EXCEL_FILE || '教育公益开放式数据库.xlsx',
SHEET_NAME: process.env.SHEET_NAME || null,
BATCH_SIZE: parseInt(process.env.BATCH_SIZE || '10'), // Default batch size
BATCH_DELAY: parseInt(process.env.BATCH_DELAY || '0'), // Default no delay
BATCH_SIZE: parseInt(process.env.BATCH_SIZE || '10'),
BATCH_DELAY: parseInt(process.env.BATCH_DELAY || '0'),
DRY_RUN: process.env.DRY_RUN === 'true',
MAX_ROWS: parseInt(process.env.MAX_ROWS || '0'),
};

// Data source generator function
async function* loadOrganizationData() {
console.log(`正在读取 Excel 文件: ${CONFIG.EXCEL_FILE}`);

// Use existing Excel reader
yield* ExcelReader.readExcelFile(CONFIG.EXCEL_FILE, CONFIG.SHEET_NAME);
}

// Main function
async function main(): Promise<void> {
let importer: DataImporter | null = null;
let logger: ImportLogger | null = null;

// Handle process signals to ensure logs are saved on forced exit
const handleExit = (signal: string) => {
console.log(`\n收到 ${signal} 信号,正在保存日志...`);
if (importer?.logger) {
importer.logger.saveToFiles();
if (logger) {
logger.saveToFiles();
console.log('日志已保存,程序退出。');
}
process.exit(0);
Expand All @@ -57,75 +59,41 @@ async function main(): Promise<void> {
throw new Error('请设置 STRAPI_TOKEN 环境变量或使用 DRY_RUN=true');
}

const excelPath = path.join(process.cwd(), CONFIG.EXCEL_FILE);
if (!fs.existsSync(excelPath)) {
throw new Error(`Excel 文件不存在: ${excelPath}`);
if (CONFIG.DRY_RUN) {
console.log('🔥 DRY RUN 模式 - 不会实际创建数据\n');
}

// Read Excel data
console.log('读取 Excel 数据...');
const rawData = ExcelReader.readExcelFile(excelPath, CONFIG.SHEET_NAME);

// Limit data for testing
const limitedData =
CONFIG.MAX_ROWS > 0 ? rawData.slice(0, CONFIG.MAX_ROWS) : rawData;
if (CONFIG.MAX_ROWS > 0) {
console.log(
`限制导入数据量: ${limitedData.length} 行 (总共 ${rawData.length} 行)`,
);
}
// Initialize logger
logger = new ImportLogger();

// Transform data format with user support
console.log('转换数据格式...');
const organizations = limitedData
.map((row) => {
try {
const organization = DataTransformer.transformOrganization(row);

// Extract user data from the same row
const userData = UserTransformer.transformUser(row);

// Attach user data for later processing using WeakMap
if (userData) {
userWeakMap.set(organization, userData);
}

return organization;
} catch (error: any) {
const orgName = row['常用名称'] || row.name || 'Unknown';
console.warn(`转换数据失败,跳过行: ${orgName}`, error.message);
return null;
}
})
.filter((org): org is OrganizationData => org !== null && !!org.name);

console.log(`转换完成,准备导入 ${organizations.length} 个组织\n`);

// Show examples in dry run mode
if (CONFIG.DRY_RUN) {
console.log('=== DRY RUN 模式 ===');
for (const [index, org] of organizations.slice(0, 3).entries()) {
console.log(`示例 ${index + 1}:`, JSON.stringify(org, null, 2));
}
console.log('==================\n');
// Create migrator instance
const migrator = new RestMigrator(
loadOrganizationData,
TargetOrganizationModel,
migrationMapping,
logger,
);

console.log('开始数据迁移...\n');

let count = 0;
for await (const organization of migrator.boot()) {
count++;
}

// Initialize API client and importer
const api = new StrapiAPI(CONFIG.STRAPI_URL, CONFIG.STRAPI_TOKEN);
importer = new DataImporter(
api,
userWeakMap,
CONFIG.BATCH_SIZE,
CONFIG.BATCH_DELAY,
CONFIG.DRY_RUN,
);
// Print final statistics
logger.printStats();

// Start import
await importer.importOrganizations(organizations);
console.log('\n导入完成!');

console.log('导入完成!');
// Save logs to files
await logger.saveToFiles();
} catch (error: any) {
console.error('导入失败:', error.message);
console.error('错误堆栈:', error.stack);

await logger?.saveToFiles();

process.exit(1);
}
}
Expand All @@ -136,9 +104,9 @@ function parseArgs(): void {

if (args.includes('--help') || args.includes('-h')) {
console.log(`
Strapi 数据导入工具 (增强版)
Strapi 数据导入工具

支持同时导入组织信息和联系人用户,并自动建立关联关系
支持从 Excel 文件导入 NGO 组织数据到 Strapi 数据库

用法:
tsx scripts/import-data.ts [选项]
Expand All @@ -152,33 +120,24 @@ Strapi 数据导入工具 (增强版)
STRAPI_TOKEN Strapi API Token
EXCEL_FILE Excel 文件路径 (默认: 教育公益开放式数据库.xlsx)
SHEET_NAME 工作表名称 (默认: 使用第一个工作表)
BATCH_SIZE 批次大小 (默认: 10)
BATCH_DELAY 批次间延迟秒数 (默认: 0, 表示无延迟)
MAX_ROWS 最大导入行数 (默认: 0, 表示导入所有行)
DRY_RUN 模拟模式 (true/false)

功能特性:
- 导入组织基本信息
- 自动创建联系人用户账户
- 建立组织与用户的关联关系
- 支持用户名冲突自动处理
- 重复检查和错误处理
BATCH_SIZE 批次大小 (默认: 10) - 由迁移框架自动处理
BATCH_DELAY 批次间延迟秒数 (默认: 0) - 由迁移框架自动处理
MAX_ROWS 最大处理行数 (默认: 0,表示全部)
DRY_RUN 模拟运行 (true/false, 默认: false)
VERBOSE_LOGGING 详细日志 (true/false, 默认: false)

示例:
# 正常导入
STRAPI_TOKEN=your_token tsx import-data.ts

# 模拟导入
DRY_RUN=true tsx import-data.ts
# 基本使用
STRAPI_TOKEN=your_token tsx scripts/import-data.ts

# 导入指定工作表
SHEET_NAME="甘肃省" STRAPI_TOKEN=your_token tsx import-data.ts
# 指定工作表
SHEET_NAME="甘肃省" STRAPI_TOKEN=your_token tsx scripts/import-data.ts

# 仅测试前10行
MAX_ROWS=10 DRY_RUN=true tsx import-data.ts
MAX_ROWS=10 DRY_RUN=true tsx scripts/import-data.ts

# 设置批次间延迟
BATCH_DELAY=2 STRAPI_TOKEN=your_token tsx import-data.ts
# 设置详细日志
VERBOSE_LOGGING=true STRAPI_TOKEN=your_token tsx scripts/import-data.ts
`);
process.exit(0);
}
Expand All @@ -188,10 +147,5 @@ Strapi 数据导入工具 (增强版)
}
}

// Entry point
if (require.main === module) {
parseArgs();
main();
}

export { DataTransformer, ExcelReader, DataImporter, StrapiAPI };
parseArgs();
main();
103 changes: 64 additions & 39 deletions scripts/transformers/data-transformer.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,74 @@
import { OrganizationData, Organization } from '../types';
import { MigrationSchema, TargetPatch } from 'mobx-restful-migrator';

import { SourceOrganization, TargetOrganization } from '../types';
import { AddressTransformer } from './address-transformer';
import { DateTransformer } from './date-transformer';
import { ServiceTransformer } from './service-transformer';
import { QualificationTransformer } from './qualification-transformer';
import { UserTransformer } from './user-transformer';
import { DataUtils } from '../utils/data-utils';
import { TargetUserModel } from '../utils/strapi-api';

export class DataTransformer {
static transformOrganization = (
organization: Organization,
): OrganizationData => ({
name: organization['常用名称'] || organization.name || '',
code: organization['机构信用代码'] || organization.code || '',
entityType: DataUtils.transformEntityType(
organization['实体类型'] || organization.entityType,
),
registrationCountry: DataUtils.transformRegistrationCountry(
organization['注册国籍'] || organization.registrationCountry,
),
establishedDate: DateTransformer.parseDate(
organization['成立时间'] || organization.establishedDate,
),
coverageArea: ServiceTransformer.extractCoverageFromDescription(
organization['机构/项目简介'] || organization.description,
),
description: DataUtils.cleanDescription(
organization['机构/项目简介'] || organization.description || '',
),
staffCount: DataUtils.parseStaffCount(
organization['机构/项目全职人数'] || organization.staffCount,
),
address: AddressTransformer.transformAddress({
export const migrationMapping: MigrationSchema<
SourceOrganization,
TargetOrganization
> = {
常用名称: ({ 常用名称: value }) => ({ name: { value, unique: true } }),
机构信用代码: 'code',
实体类型: ({ 实体类型: value }) => ({
entityType: { value: DataUtils.transformEntityType(value) },
}),
注册国籍: ({ 注册国籍: value }) => ({
registrationCountry: {
value: DataUtils.transformRegistrationCountry(value),
},
}),
成立时间: ({ 成立时间: value }) => ({
establishedDate: { value: DateTransformer.parseDate(value) },
}),
'机构/项目简介': ({ ['机构/项目简介']: value }) => {
value ||= '';
return {
description: { value: DataUtils.cleanDescription(value) },
coverageArea: {
value: ServiceTransformer.extractCoverageFromDescription(value),
},
};
},
'机构/项目全职人数': ({ ['机构/项目全职人数']: value }) => ({
staffCount: { value: DataUtils.parseStaffCount(value) },
}),
注册地: ({ 注册地, 具体地址 }) => {
const addressData = {
province: AddressTransformer.extractProvinceFromAddress(
organization['注册地'] || organization['具体地址'],
),
city: AddressTransformer.extractCityFromAddress(
organization['注册地'] || organization['具体地址'],
注册地 || 具体地址,
),
city: AddressTransformer.extractCityFromAddress(注册地 || 具体地址),
district: AddressTransformer.extractDistrictFromAddress(
organization['注册地'] || organization['具体地址'],
注册地 || 具体地址,
),
street: organization['具体地址'] || organization.address?.street || '',
}),
services: ServiceTransformer.transformServices(organization),
internetContact: ServiceTransformer.transformContacts(organization),
qualifications:
QualificationTransformer.transformQualifications(organization),
});
}
street: 具体地址 || '',
};
return {
address: { value: AddressTransformer.transformAddress(addressData) },
};
},
机构官网: (row) => ({
services: { value: ServiceTransformer.transformServices(row) },
}),
机构微信公众号: (row) => ({
internetContact: { value: ServiceTransformer.transformContacts(row) },
}),
登记管理机关: (org) => ({
qualifications: {
value: QualificationTransformer.transformQualifications(org),
},
}),
机构联系人联系人姓名: (org) => {
const value = UserTransformer.transformUser(org);

return (
!value ? {} : { contactUser: { value, model: TargetUserModel } }
) as TargetPatch<TargetOrganization>;
},
};
Loading