Skip to content

Commit e2b34b6

Browse files
[add] User import logic (#5)
Co-authored-by: TechQuery <shiy2008@gmail.com>
1 parent d84a084 commit e2b34b6

File tree

11 files changed

+2232
-2277
lines changed

11 files changed

+2232
-2277
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ coverage
124124
############################
125125

126126
.env
127+
.env*.local
127128
license.txt
128129
exports
129130
dist

package.json

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,40 +7,40 @@
77
"npm": ">=6.0.0"
88
},
99
"dependencies": {
10-
"@arshiash80/strapi-plugin-iconhub": "^1.0.3",
10+
"@arshiash80/strapi-plugin-iconhub": "^1.1.1",
1111
"@ckeditor/strapi-plugin-ckeditor": "^1.1.1",
12-
"@strapi/plugin-cloud": "~5.18.1",
13-
"@strapi/plugin-color-picker": "~5.18.1",
14-
"@strapi/plugin-documentation": "~5.18.1",
15-
"@strapi/plugin-users-permissions": "~5.18.1",
16-
"@strapi/strapi": "~5.18.1",
12+
"@strapi/plugin-cloud": "~5.23.5",
13+
"@strapi/plugin-color-picker": "~5.23.5",
14+
"@strapi/plugin-documentation": "~5.23.5",
15+
"@strapi/plugin-users-permissions": "~5.23.5",
16+
"@strapi/strapi": "~5.23.5",
1717
"better-sqlite3": "^12.2.0",
18-
"cross-env": "^7.0.3",
18+
"cross-env": "^10.0.0",
1919
"pg": "^8.16.3",
2020
"react": "^18.3.1",
2121
"react-dom": "^18.3.1",
2222
"react-router-dom": "^6.30.1",
23-
"strapi-location-picker": "^0.0.1",
23+
"strapi-location-picker": "^0.0.2",
2424
"strapi-plugin-config-sync": "^3.1.2",
2525
"strapi-plugin-multi-select": "^2.1.1",
2626
"strapi-provider-cloudflare-r2": "^0.3.0",
2727
"styled-components": "^6.1.19"
2828
},
2929
"devDependencies": {
30-
"@types/node": "^22.16.5",
31-
"@types/react": "^18.3.23",
30+
"@types/node": "^22.18.6",
31+
"@types/react": "^18.3.24",
3232
"@types/react-dom": "^18.3.7",
33-
"get-git-folder": "^0.1.2",
3433
"husky": "^9.1.7",
3534
"koajax": "^3.1.2",
36-
"lint-staged": "^16.1.2",
35+
"lint-staged": "^16.1.6",
36+
"mobx-strapi": "^0.8.1",
3737
"prettier": "^3.6.2",
38-
"swagger-typescript-api": "^13.2.7",
39-
"tsx": "^4.20.3",
40-
"typescript": "~5.8.3",
41-
"web-utility": "^4.5.1",
38+
"swagger-typescript-api": "^13.2.11",
39+
"tsx": "^4.20.5",
40+
"typescript": "~5.9.2",
41+
"web-utility": "^4.6.1",
4242
"xlsx": "^0.18.5",
43-
"zx": "^8.7.1"
43+
"zx": "^8.8.1"
4444
},
4545
"pnpm": {
4646
"onlyBuiltDependencies": [
@@ -59,7 +59,7 @@
5959
"*.{md,json,yml,js,mjs,ts,tsx}": "prettier --write"
6060
},
6161
"scripts": {
62-
"install": "get-git-folder https://github.com/Open-Source-Bazaar/key-vault main China-NGO-database || true",
62+
"install": "pnpx git-utility download https://github.com/Open-Source-Bazaar/key-vault main China-NGO-database || true",
6363
"prepare": "(husky || true) && (cp -u .env.example .env || true)",
6464
"test": "lint-staged",
6565
"upgrade": "npx @strapi/upgrade latest",

pnpm-lock.yaml

Lines changed: 1783 additions & 2169 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/import-data.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
77

88
import * as fs from 'node:fs';
99
import * as path from 'node:path';
10-
import { Config, OrganizationData } from './types';
10+
import { Config, OrganizationData, ExtendedUserData } from './types';
11+
12+
// Create WeakMap to store user data for organizations
13+
const userWeakMap = new WeakMap<OrganizationData, ExtendedUserData>();
1114

1215
// Import refactored modules
1316
import { DataTransformer } from './transformers/data-transformer';
17+
import { UserTransformer } from './transformers/user-transformer';
1418
import { ExcelReader } from './utils/excel-reader';
1519
import { StrapiAPI } from './utils/strapi-api';
1620
import { DataImporter } from './utils/data-importer';
@@ -21,7 +25,8 @@ const CONFIG: Config = {
2125
STRAPI_TOKEN: process.env.STRAPI_TOKEN || '',
2226
EXCEL_FILE: process.env.EXCEL_FILE || '教育公益开放式数据库.xlsx',
2327
SHEET_NAME: process.env.SHEET_NAME || null,
24-
BATCH_SIZE: parseInt(process.env.BATCH_SIZE || '10'),
28+
BATCH_SIZE: parseInt(process.env.BATCH_SIZE || '10'), // Default batch size
29+
BATCH_DELAY: parseInt(process.env.BATCH_DELAY || '0'), // Default no delay
2530
DRY_RUN: process.env.DRY_RUN === 'true',
2631
MAX_ROWS: parseInt(process.env.MAX_ROWS || '0'),
2732
};
@@ -70,12 +75,22 @@ async function main(): Promise<void> {
7075
);
7176
}
7277

73-
// Transform data format
78+
// Transform data format with user support
7479
console.log('转换数据格式...');
7580
const organizations = limitedData
7681
.map((row) => {
7782
try {
78-
return DataTransformer.transformOrganization(row);
83+
const organization = DataTransformer.transformOrganization(row);
84+
85+
// Extract user data from the same row
86+
const userData = UserTransformer.transformUser(row);
87+
88+
// Attach user data for later processing using WeakMap
89+
if (userData) {
90+
userWeakMap.set(organization, userData);
91+
}
92+
93+
return organization;
7994
} catch (error: any) {
8095
const orgName = row['常用名称'] || row.name || 'Unknown';
8196
console.warn(`转换数据失败,跳过行: ${orgName}`, error.message);
@@ -97,7 +112,13 @@ async function main(): Promise<void> {
97112

98113
// Initialize API client and importer
99114
const api = new StrapiAPI(CONFIG.STRAPI_URL, CONFIG.STRAPI_TOKEN);
100-
importer = new DataImporter(api, CONFIG.BATCH_SIZE, CONFIG.DRY_RUN);
115+
importer = new DataImporter(
116+
api,
117+
userWeakMap,
118+
CONFIG.BATCH_SIZE,
119+
CONFIG.BATCH_DELAY,
120+
CONFIG.DRY_RUN,
121+
);
101122

102123
// Start import
103124
await importer.importOrganizations(organizations);
@@ -115,7 +136,9 @@ function parseArgs(): void {
115136

116137
if (args.includes('--help') || args.includes('-h')) {
117138
console.log(`
118-
Strapi 数据导入工具
139+
Strapi 数据导入工具 (增强版)
140+
141+
支持同时导入组织信息和联系人用户,并自动建立关联关系。
119142
120143
用法:
121144
tsx scripts/import-data.ts [选项]
@@ -130,9 +153,17 @@ Strapi 数据导入工具
130153
EXCEL_FILE Excel 文件路径 (默认: 教育公益开放式数据库.xlsx)
131154
SHEET_NAME 工作表名称 (默认: 使用第一个工作表)
132155
BATCH_SIZE 批次大小 (默认: 10)
156+
BATCH_DELAY 批次间延迟秒数 (默认: 0, 表示无延迟)
133157
MAX_ROWS 最大导入行数 (默认: 0, 表示导入所有行)
134158
DRY_RUN 模拟模式 (true/false)
135159
160+
功能特性:
161+
- 导入组织基本信息
162+
- 自动创建联系人用户账户
163+
- 建立组织与用户的关联关系
164+
- 支持用户名冲突自动处理
165+
- 重复检查和错误处理
166+
136167
示例:
137168
# 正常导入
138169
STRAPI_TOKEN=your_token tsx import-data.ts
@@ -142,6 +173,12 @@ Strapi 数据导入工具
142173
143174
# 导入指定工作表
144175
SHEET_NAME="甘肃省" STRAPI_TOKEN=your_token tsx import-data.ts
176+
177+
# 仅测试前10行
178+
MAX_ROWS=10 DRY_RUN=true tsx import-data.ts
179+
180+
# 设置批次间延迟
181+
BATCH_DELAY=2 STRAPI_TOKEN=your_token tsx import-data.ts
145182
`);
146183
process.exit(0);
147184
}

scripts/transformers/data-transformer.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,5 @@ export class DataTransformer {
4545
internetContact: ServiceTransformer.transformContacts(organization),
4646
qualifications:
4747
QualificationTransformer.transformQualifications(organization),
48-
publishedAt: new Date().toISOString(),
4948
});
5049
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { randomBytes } from 'node:crypto';
2+
3+
import { ExtendedUserData, Organization } from '../types';
4+
5+
export class UserTransformer {
6+
static transformUser = (
7+
organization: Organization,
8+
): ExtendedUserData | null => {
9+
// 获取用户信息
10+
const contactName = String(
11+
organization['机构联系人联系人姓名'] ?? '',
12+
).trim();
13+
const contactPhone = String(
14+
organization['机构联系人联系人电话'] ?? '',
15+
).trim();
16+
const contactEmail = String(organization['机构联系人联系人邮箱'] ?? '')
17+
.trim()
18+
.toLowerCase()
19+
.split(/[^\w-@.]+/)[0];
20+
const principalName = String(organization['负责人'] ?? '').trim();
21+
22+
// 检查是否有联系方式(邮箱或手机)
23+
const hasValidEmail = contactEmail.includes('@');
24+
const phoneDigits = contactPhone.replace(/\D/g, '');
25+
const hasValidPhone = phoneDigits.length > 0;
26+
27+
// 如果没有任何联系方式,则不创建用户
28+
if (!hasValidEmail && !hasValidPhone) {
29+
return null;
30+
}
31+
32+
// 生成邮箱:优先使用真实邮箱,如果没有则用手机号生成系统邮箱
33+
let email: string;
34+
if (hasValidEmail) {
35+
email = contactEmail;
36+
} else {
37+
// 生成不可投递的占位邮箱(仅用于存储联系信息)
38+
const local = phoneDigits || `u${Date.now().toString(36).slice(-6)}`;
39+
email = `${local}@example.invalid`;
40+
}
41+
42+
// 生成用户名:优先使用联系人姓名,没有则使用负责人,最后使用组织名
43+
const organizationName = organization['常用名称'] || organization.name;
44+
const baseUsername =
45+
contactName || principalName || organizationName || `user_${Date.now()}`;
46+
47+
// 生成唯一的用户名:基础用户名 + 组织名称(确保唯一性)
48+
const orgCleanName = organizationName
49+
? organizationName.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '')
50+
: 'org';
51+
52+
// 清理用户名,移除特殊字符
53+
const cleanBaseUsername = baseUsername.replace(
54+
/[^a-zA-Z0-9\u4e00-\u9fa5]/g,
55+
'',
56+
);
57+
58+
const safeBase = cleanBaseUsername || 'user';
59+
// 生成带序号的用户名格式
60+
// 生成唯一用户名(序号从1开始)
61+
// 注意:这里只是生成用户名格式,实际唯一性检查在导入脚本中进行
62+
let username = ((suffix: string = ''): string =>
63+
safeBase === orgCleanName
64+
? `${safeBase}${suffix}`
65+
: `${safeBase}_${orgCleanName}${suffix}`)();
66+
67+
return {
68+
username,
69+
email,
70+
password: randomBytes(18).toString('base64url').slice(0, 24), // 强随机密码
71+
confirmed: false, // 不需要确认
72+
blocked: true, // 阻止登录
73+
provider: 'local',
74+
phone: contactPhone.replace(/\D/g, '') || undefined,
75+
// 设置默认角色(通常 authenticated 用户角色的 ID 是 1)
76+
role: 1,
77+
} as ExtendedUserData;
78+
};
79+
80+
static extractPrincipalName = (organization: Organization): string =>
81+
String(organization['负责人'] ?? '').trim();
82+
83+
static extractContactInfo = (
84+
organization: Organization,
85+
): {
86+
name: string;
87+
phone: string;
88+
email: string;
89+
} => ({
90+
name: String(organization['机构联系人联系人姓名'] ?? '').trim(),
91+
phone: String(organization['机构联系人联系人电话'] ?? '').trim(),
92+
email: String(organization['机构联系人联系人邮箱'] ?? '')
93+
.trim()
94+
.toLowerCase(),
95+
});
96+
}

scripts/types.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
* 类型定义模块
33
* 集中管理所有接口和类型定义
44
*/
5+
import {
6+
Organization as _Organization,
7+
UsersPermissionsRole,
8+
UsersPermissionsUser,
9+
} from '../types';
510

611
// 配置接口
712
export interface Config {
@@ -10,6 +15,7 @@ export interface Config {
1015
EXCEL_FILE: string;
1116
SHEET_NAME: string | null;
1217
BATCH_SIZE: number;
18+
BATCH_DELAY: number;
1319
DRY_RUN: boolean;
1420
MAX_ROWS: number;
1521
}
@@ -19,15 +25,29 @@ export {
1925
ServiceOrganizationServiceComponent as Service,
2026
ContactInternetContactComponent as InternetContact,
2127
QualificationCertificateComponent as Qualification,
22-
Organization as OrganizationData,
23-
UsersPermissionsUser as UserData,
2428
} from '../types';
2529

26-
// 组织数据接口
27-
type OrganizationData = import('../types').Organization;
30+
// 扩展的用户数据接口(包含自定义字段)
31+
export interface ExtendedUserData extends Omit<UsersPermissionsUser, 'id'> {
32+
// 用户创建时不需要ID,但可以包含其他可选字段
33+
id?: number;
34+
// 自定义字段
35+
phone?: string;
36+
// 其他可能需要的字段
37+
password?: string;
38+
// 角色可以是ID或完整对象
39+
role?: number | UsersPermissionsRole;
40+
}
41+
42+
export interface OrganizationData extends Omit<_Organization, 'contactUser'> {
43+
// contactUser 可以是用户对象(用于创建)或用户ID(用于引用)
44+
contactUser?: number | null;
45+
}
2846

2947
// Excel行数据接口
30-
export interface Organization extends Partial<import('../types').Organization> {
48+
export interface Organization extends Partial<_Organization> {
49+
// 添加索引签名以支持动态中文属性名访问
50+
[key: string]: any;
3151
常用名称?: string;
3252
机构信用代码?: string;
3353
实体类型?: string;
@@ -41,6 +61,10 @@ export interface Organization extends Partial<import('../types').Organization> {
4161
机构微信公众号?: string;
4262
机构微博?: string;
4363
登记管理机关?: string;
64+
负责人?: string;
65+
机构联系人联系人姓名?: string;
66+
机构联系人联系人电话?: string;
67+
机构联系人联系人邮箱?: string;
4468
}
4569

4670
// 导入统计接口

0 commit comments

Comments
 (0)