fix: 人员数据治理迁移3

This commit is contained in:
2026-11-09 16:08:24 +08:00
parent 73c8750b6a
commit bd7857a5c8
3 changed files with 244 additions and 6 deletions
@@ -140,6 +140,11 @@ public class PersonApiConfigServiceImpl extends BaseServiceImpl<PersonApiConfig>
} }
Assert.isTrue(List.of("HTTP_API", "SYSTEM_TABLE").contains(config.getSourceType()), "同步方式不正确"); Assert.isTrue(List.of("HTTP_API", "SYSTEM_TABLE").contains(config.getSourceType()), "同步方式不正确");
mappings = mappings == null ? new ArrayList<>() : mappings; mappings = mappings == null ? new ArrayList<>() : mappings;
if (Boolean.TRUE.equals(config.getEnabled())) {
Assert.hasText(config.getTargetTable(), "启用同步任务前请选择目标表");
Assert.isTrue(mappings.stream().anyMatch(mapping -> Strings.isNotBlank(mapping.getSourceField())
&& Strings.isNotBlank(mapping.getTargetField())), "启用同步任务前请至少完成一条来源字段与目标字段的映射");
}
PersonApiFieldMapping identity = mappings.stream() PersonApiFieldMapping identity = mappings.stream()
.filter(v -> Boolean.TRUE.equals(v.getIdentityFlag())) .filter(v -> Boolean.TRUE.equals(v.getIdentityFlag()))
.findFirst() .findFirst()
@@ -166,7 +166,7 @@
</div> </div>
<div class="person-governance-mapping-toolbar-item person-governance-mapping-switch"> <div class="person-governance-mapping-toolbar-item person-governance-mapping-switch">
<label>启用同步任务</label> <label>启用同步任务</label>
<el-switch v-model="form.enabled" :disabled="mappings.length===0||cronValidating" @change="handleEnabledChange"></el-switch> <el-switch v-model="form.enabled" :disabled="!hasMappedField||cronValidating" @change="handleEnabledChange"></el-switch>
</div> </div>
<div class="person-governance-mapping-toolbar-item person-governance-task-cron"> <div class="person-governance-mapping-toolbar-item person-governance-task-cron">
<label>任务周期</label> <label>任务周期</label>
@@ -258,8 +258,9 @@
<template v-slot="{row}"><el-switch v-model="row.required"></el-switch></template> <template v-slot="{row}"><el-switch v-model="row.required"></el-switch></template>
</el-table-column> </el-table-column>
<el-table-column label="唯一标识" width="100"> <el-table-column label="唯一标识" width="100">
<template v-slot="{row}"> <template v-slot="{row,$index}">
<el-radio v-model="identityRow" :label="row">设为标识</el-radio> <el-radio :value="mappings.indexOf(identityRow)" :label="$index"
:disabled="!isIdentityMapping(row)" @input="identityRow=mappings[$event]">设为标识</el-radio>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="80"> <el-table-column label="操作" width="80">
@@ -576,6 +577,11 @@
}, },
}, },
methods: { methods: {
isIdentityMapping(row) {
return Boolean(row && row.sourceField && row.sourceField.trim()
&& row.targetField && row.targetField.trim()
&& !['id', 'pullTime'].includes(row.targetField))
},
// 弹窗使用独立草稿;启停状态不限制周期编辑,取消不修改配置,未设置时预选每5分钟。 // 弹窗使用独立草稿;启停状态不限制周期编辑,取消不修改配置,未设置时预选每5分钟。
openCronDialog() { openCronDialog() {
if (this.formLoading || this.cronValidating || this.cronDialogVisible) { if (this.formLoading || this.cronValidating || this.cronDialogVisible) {
@@ -928,6 +934,12 @@
} }
}, },
save() { save() {
if (this.form.syncMode && this.form.syncMode !== 'APPEND'
&& (!this.mappings.includes(this.identityRow) || !this.isIdentityMapping(this.identityRow))) {
this.notifyWarning('请在唯一标识列选择完整的业务字段映射(例如工号 gh);目标字段不能是 id 或 pullTime')
this.$set(this, 'activeTab', 'mapping')
return
}
const formRef = this.form.sourceType === 'SYSTEM_TABLE' const formRef = this.form.sourceType === 'SYSTEM_TABLE'
? this.$refs.systemForm ? this.$refs.systemForm
: this.$refs.httpForm : this.$refs.httpForm
@@ -1033,9 +1045,6 @@
this.$set(this, 'activeTab', 'mapping') this.$set(this, 'activeTab', 'mapping')
} }
this.$set(this, 'mappings', res.data.mappings || []) this.$set(this, 'mappings', res.data.mappings || [])
if (!this.hasMappedField) {
this.$set(this.form, 'enabled', false)
}
const params = (res.data.params || []).map((v) => { const params = (res.data.params || []).map((v) => {
if (!v.requestScope) { if (!v.requestScope) {
this.$set(v, 'requestScope', 'DATA') this.$set(v, 'requestScope', 'DATA')
+224
View File
@@ -0,0 +1,224 @@
'use strict';
/*
* 人员数据治理 API 配置测试服务,仅使用 Node.js 内置模块。
* 启动此文件后默认监听 http://127.0.0.1:18080,可通过 HOST、PORT 环境变量修改。
* 所有人员信息均为模拟数据,每次启动生成相同的 100 条记录,不连接数据库。
*
* API 配置示例(地址中的主机需保证 Java 服务能够访问):
* Token 地址:http://127.0.0.1:18080/api/token,POST,JSON 请求体 {}
* Access_Token 路径:data->access_token
* Expires_in 路径:data->expires_in,单位:SECONDS
* 数据地址:http://127.0.0.1:18080/api/persons,GET 或 POST
* 返回码路径:code,成功返回值:0,消息路径:msg
* 人员数组路径:data->list,总数路径:data->total
* 数据字段按人员接口字段表生成,全部为字符串;码值为模拟值。
* 字段映射示例:gh→loginname、xm→username、xb→sex、sjhm→mobile、yx→email、lxrq→arrivalAtSchoolDate。
* gh、grbsm 均为唯一标识,可按实际目标中间表字段配置。
* 分页 Query 参数:pageNumber=${页码}、pageSize=${数量},例如起始页码 1、数量 20。
* POST JSON 也可使用 {"pageNumber":"${页码}","pageSize":"${数量}"}。
* 不配置分页参数时一次返回全部 100 条数据。
* 默认不要求认证;MOCK_REQUIRE_TOKEN=1 时,数据接口要求以下任意一种凭证:
* Header Authorization: Bearer ${Access_Token}
* Query 或 JSON 参数 access_token: ${Access_Token}
*/
const http = require('node:http');
const { URL } = require('node:url');
const HOST = process.env.HOST || '127.0.0.1';
const PORT = Number(process.env.PORT || 18080);
const REQUIRE_TOKEN = process.env.MOCK_REQUIRE_TOKEN === '1';
const TOKEN = 'person-governance-mock-token';
const MAX_BODY_BYTES = 1024 * 1024;
const persons = Array.from({ length: 100 }, (_, index) => {
const number = index + 1;
const suffix = String(number).padStart(3, '0');
const department = index % 5;
const departmentCode = `MOCK-D${department + 1}`;
const departmentName = ['计算机学院', '机械工程学院', '经济管理学院', '外国语学院', '教务处'][department];
const female = number % 2 === 0;
const retired = number % 10 === 0;
const birthYear = retired ? 1960 + index % 5 : 1980 + index % 15;
const month = String(index % 12 + 1).padStart(2, '0');
const day = String(index % 28 + 1).padStart(2, '0');
const leader = number % 20 === 0;
return {
xm: `测试人员${suffix}`,
gh: `MOCK${suffix}`,
bmmz: departmentCode,
bm: departmentName,
zcszbm: departmentName,
zcszbmmz: departmentCode,
nl: String(2026 - birthYear),
jg: '江苏南京',
jgmz: '320100',
xb: female ? '女' : '男',
xbmz: female ? '2' : '1',
csrq: `${birthYear}-${month}-${day}`,
mz: '汉族',
mzmz: '01',
gjmz: '156',
gj: '中国',
zzmm: number % 3 === 0 ? '中共党员' : '群众',
zzmmmz: number % 3 === 0 ? '01' : '13',
zjlx: '测试证件',
zjlxmz: 'MOCK',
zjhm: `MOCK-ID-${suffix}`,
lxrq: `2020-${month}-01`,
zgqk: retired ? '退休' : '在岗',
zgqkmz: retired ? '02' : '01',
ryxz: retired ? '离退休人员' : '在职人员',
ryxzmz: retired ? '02' : '01',
yrxs: '事业编制',
yrxsmz: '01',
qzjz: '全职',
qzjzmz: '01',
xrzw: leader ? '部门负责人' : '教师',
zwjb: leader ? '正处级' : '无',
zwjbmz: leader ? '01' : '00',
rxzsj: '2022-09-01',
zgxl: number % 3 === 0 ? '硕士研究生' : '博士研究生',
zgxlmz: number % 3 === 0 ? '14' : '11',
rkxymz: departmentCode,
rkxy: departmentName,
gwlb: '专业技术岗',
gwlbmz: '01',
gwdj: '专业技术七级',
gwdjmz: '07',
gwmc: '教学科研岗',
gwmcmz: '01',
grjsdj: '无',
grjsdjmz: '00',
grdjsj: '',
sjhm: `00000000${suffix}`,
rysfmz: '01',
rysf: '教职工',
daszdw: '测试学校档案馆',
grbsm: `MOCK-PERSON-${suffix}`,
gwxz: '固定岗位',
gwxzmz: '01',
dezc: '无',
dezcmz: '00',
dezcjb: '无',
dezcjbmz: '00',
sfbmfzr: leader ? '是' : '否',
sfbmfzrmz: leader ? '1' : '0',
xldfgbm: '',
xldfgbmmz: '',
zcldfzbmmz: leader ? departmentCode : '',
zcldfzbm: leader ? departmentName : '',
zjsj: '2022-09-01',
xzw2: '',
zw2rzsj: '',
xzw3: '',
xzw4: '',
fdylb: '非辅导员',
fdylbmz: '00',
yx: `mock${suffix}@example.com`,
lxdh: `00000000${suffix}`,
qq: String(900000000 + number),
gz: '教学'
};
});
function reply(response, status, code, msg, data = null) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
response.end(JSON.stringify({ code, msg, data }));
}
function readBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
let bytes = 0;
let tooLarge = false;
request.on('data', chunk => {
bytes += chunk.length;
if (bytes > MAX_BODY_BYTES) {
if (!tooLarge) {
tooLarge = true;
reject(Object.assign(new Error('请求体超过 1MB'), { status: 413 }));
}
return;
}
chunks.push(chunk);
});
request.on('error', reject);
request.on('end', () => {
if (tooLarge) return;
const body = Buffer.concat(chunks).toString('utf8');
if (!body.trim()) return resolve({});
try {
const contentType = (request.headers['content-type'] || '').split(';')[0].trim();
const value = contentType === 'application/x-www-form-urlencoded'
? Object.fromEntries(new URLSearchParams(body)) : JSON.parse(body);
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('请求体必须为对象');
}
resolve(value);
} catch (error) {
reject(Object.assign(new Error('请求体须为 JSON 对象或表单参数'), { status: 400 }));
}
});
});
}
function positiveInteger(value, fallback, name) {
if (value === undefined) return fallback;
if (!/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value)) || Number(value) < 1) {
throw Object.assign(new Error(`${name} 必须为正整数`), { status: 400 });
}
return Number(value);
}
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://localhost');
if (!['/api/token', '/api/persons'].includes(url.pathname)) {
request.resume();
return reply(response, 404, 404, '接口不存在');
}
if (!['GET', 'POST'].includes(request.method)) {
request.resume();
response.setHeader('Allow', 'GET, POST');
return reply(response, 405, 405, '仅支持 GET 和 POST');
}
const body = await readBody(request);
const params = { ...Object.fromEntries(url.searchParams), ...body };
if (url.pathname === '/api/token') {
return reply(response, 200, 0, '成功', {
access_token: TOKEN, expires_in: 7200, token_type: 'Bearer'
});
}
if (REQUIRE_TOKEN && request.headers.authorization !== `Bearer ${TOKEN}`
&& params.access_token !== TOKEN) {
return reply(response, 401, 401, 'Token 无效或未提供');
}
const pageNumber = positiveInteger(params.pageNumber ?? params.page, 1, 'pageNumber');
const pageSize = positiveInteger(params.pageSize, 100, 'pageSize');
if (pageSize > 1000) {
return reply(response, 400, 400, 'pageSize 不能超过 1000');
}
const start = (pageNumber - 1) * pageSize;
return reply(response, 200, 0, '成功', {
list: persons.slice(start, start + pageSize),
total: persons.length,
pageNumber,
pageSize
});
} catch (error) {
const status = error.status || 500;
reply(response, status, status, status === 500 ? '测试服务处理失败' : error.message);
}
});
server.on('error', error => {
console.error(`测试服务启动失败:${error.message}`);
process.exitCode = 1;
});
server.listen(PORT, HOST, () => {
console.log(`人员治理测试服务:http://${HOST}:${PORT},已预填充 ${persons.length} 条模拟数据`);
console.log('Token 接口:/api/token;人员接口:/api/persons');
});