feat(customer): 新增ICC客户信息同步功能 - OAuth2密码模式鉴权拉取org_base客户、按icc_code幂等upsert写入并记录icc_sync_log,注册每10分钟定时任务;客户档案管理页新增手动'同步客户'按钮(POST /api/customers/sync/icc),同步后自动刷新列表与统计;customer表新增icc_code字段及唯一索引,同步db_seed与设计文档
This commit is contained in:
1 parent
be91d04431
commit
9d3bb13ecf
24 files changed
+1159
-5
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
# Checklist
|
||||||
|
|
||||||
|
- [x] customer 表迁移与 db_init.sql 均包含 icc_code 字段与唯一索引,且结构一致(migrations/20261016_icc_customer_sync.sql ↔ db_init.sql L194/L232/L213 ↔ customer.entity.ts)
|
||||||
|
- [x] customer 实体含 iccCode 字段定义(field=icc_code,STRING(64),可空)
|
||||||
|
- [x] ICC OAuth2 鉴权客户端可获取公钥并完成 RSA 加密、换取 access_token,token 有进程内缓存与过期续签(icc_customer_auth.ts,RSA 分块加解密冒烟验证通过)
|
||||||
|
- [x] 字段映射器正确映射 orgCode→iccCode、orgName→customerName、province/city/county,并解析分页响应(icc_customer_mapper.ts,冒烟验证映射与 PageVO/数组兼容解析均正确)
|
||||||
|
- [x] 同步服务按 org_base 过滤、分页全量拉取(maxRangeId=0、pageSize=1000 循环至末页),按 icc_code 幂等 upsert
|
||||||
|
- [x] 新增客户自动生成 customer_no(复用 CustomerService.generateCustomerNo,KH+YYYYMMDD+3位序号);已存在客户先比较四个映射字段,仅不一致才更新,其余字段保持不变
|
||||||
|
- [x] 每次同步写 icc_sync_log(sync_type=customer,status/record_count/error_message),失败路径仅记日志不抛业务错误
|
||||||
|
- [x] 定时任务已注册(job_registry L5/L37/L64 + db_seed.sql L411 sys_job 种子),默认 cron `*/10 * * * *`,调度器运行时已接线(job_scheduler.service.ts syncIccCustomers)
|
||||||
|
- [x] server/.env 含完整 ICC 配置键(BASE_URL/VERSION/CLIENT_ID/CLIENT_SECRET/USERNAME/PASSWORD)
|
||||||
|
- [x] `yarn build` 编译通过(mapper 修复后复验通过),任务代码路径(task → runtime → service)类型正确可运行;端到端写库同步建议在部署环境首次执行时由真实 ICC 接口确认
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# ICC 客户信息同步(org_base)Spec
|
||||||
|
|
||||||
|
## Why
|
||||||
|
客户档案目前依赖手工维护,鑫潼 ICC 平台已存在基础组织(org_base)客户主数据。需按 ICC 平台接口文档,通过定时任务从 ICC 拉取 org_base 组织数据,按字段映射对 customer 客户表执行新增/变更,实现客户主数据自动化同步。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- **新增** `customer` 表 `icc_code` 字段(ICC 客户编码,唯一索引),作为 ICC 同步的幂等匹配键;同步修改实体、迁移脚本与 db_seed.sql(幂等一致)
|
||||||
|
- **新增** ICC OAuth2 密码模式鉴权客户端:获取公钥 → RSA(ECB/PKCS1) 加密密码 → `extend/token` 换取 access_token(2h 有效,进程内缓存过期自动续签)
|
||||||
|
- **新增** `IccCustomerSyncService`:从 `.env` 读取 ICC 配置,分页全量拉取 `organization/page`(`maxRangeId=0`,过滤 `orgType=org_base`),按字段映射对 customer 幂等 upsert(已存在记录先比较映射字段,不一致才变更),写 `icc_sync_log`
|
||||||
|
- **新增** 定时任务:注册 `icc_customer_sync.task` 到任务注册表与 `sys_job`(默认每 10 分钟,可配置)
|
||||||
|
- **安全**:全部 SQL 走 Sequelize 参数绑定;接口凭据经 `.env` 读取,不硬编码到代码;ICC 拉取失败仅记录 `icc_sync_log` 错误日志,不抛业务错误
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Affected specs: ICC 数据同步(含现有 icc_line_sync 模式)、客户管理(customer 主数据)
|
||||||
|
- Affected code:
|
||||||
|
- server/src/entity/customer.entity.ts(新增 iccCode)
|
||||||
|
- server/src/task/icc_customer_mapper.ts(新增:字段映射集中配置)
|
||||||
|
- server/src/service/icc_customer_sync.service.ts(新增:OAuth2 鉴权 + 拉取 + upsert + 日志)
|
||||||
|
- server/src/task/icc_customer_sync.task.ts(新增:定时任务定义)
|
||||||
|
- server/src/task/job_registry.ts、server/src/service/job_scheduler.service.ts(注册任务与运行时)
|
||||||
|
- server/.env(新增 ICC 凭据配置项)
|
||||||
|
- database/migrations/(新增迁移文件)、database/db_seed.sql(customer 表结构与 sys_job 种子)
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: ICC 客户信息同步(org_base 定时任务)
|
||||||
|
系统 SHALL 通过定时任务从 ICC 平台同步 org_base 客户信息到 customer 表:OAuth2 鉴权获取 access_token → 分页全量拉取组织数据(maxRangeId=0,pageSize≤1000,循环至末页)→ 过滤 `orgType=org_base` → 按字段映射(`orgCode→iccCode`、`orgName→customerName`、`province→province`、`city→city`、`county→district`)幂等 upsert,并写 `icc_sync_log`(sync_type=`customer`)。
|
||||||
|
|
||||||
|
#### Scenario: 定时同步成功
|
||||||
|
- **WHEN** 定时任务触发(默认 `*/10 * * * *`,可配置)
|
||||||
|
- **THEN** 拉取 ICC org_base 组织并新增/变更对应 customer 记录;新增客户自动生成 `customer_no`(沿用 KH+YYYYMMDD+序号规则);已存在客户(按 `icc_code` 匹配)先比较 customer_name/province/city/district 四个映射字段与现有值,仅当任一字段不一致时才执行更新,一致则跳过,其余手工字段(status/salesman_id/联系方式等)保持不变;`icc_sync_log` 写入成功记录
|
||||||
|
|
||||||
|
#### Scenario: 重复执行幂等
|
||||||
|
- **WHEN** 同一 orgCode 连续两次同步均存在且 ICC 数据未变化
|
||||||
|
- **THEN** 第二次执行按 icc_code 命中已有记录,比较后字段一致不执行 update,不产生重复客户、不产生无意义变更
|
||||||
|
|
||||||
|
#### Scenario: 全量分页拉取
|
||||||
|
- **WHEN** 组织数据超过一页
|
||||||
|
- **THEN** 按 pageNum 递增循环请求直至 currentPage==totalPage,全部合并后统一 upsert
|
||||||
|
|
||||||
|
#### Scenario: ICC 鉴权失败 / 接口异常
|
||||||
|
- **WHEN** 公钥获取失败、token 换取失败或组织接口返回非 0 code / HTTP 非 2xx
|
||||||
|
- **THEN** 同步失败仅写 `icc_sync_log`(status=0、error_message),业务不抛错;access_token 过期时自动重新走 OAuth2 鉴权
|
||||||
|
|
||||||
|
### Requirement: customer 表 icc_code 字段
|
||||||
|
系统 SHALL 在 customer 表新增 `icc_code` 字段(字符串,可空,唯一索引)用于承载 ICC 客户编码;已有客户在首次同步时按 orgCode 匹配后回填该字段;该字段不参与客户档案页面手工编辑逻辑(同步来源字段)。
|
||||||
|
|
||||||
|
### Requirement: ICC 凭据环境配置
|
||||||
|
系统 SHALL 通过 `.env` 读取 ICC 平台连接配置:`ICC_BASE_URL`(host)、`ICC_VERSION`、`ICC_CLIENT_ID`、`ICC_CLIENT_SECRET`、`ICC_USERNAME`、`ICC_PASSWORD`;业务代码不出现明文凭据。
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
(无既有功能被修改;现有 icc_line_sync 任务保持原行为不变)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] Task 1: customer 表新增 icc_code 字段(数据库层)
|
||||||
|
- [x] 新建迁移文件 `database/migrations/20261016_icc_customer_sync.sql`:`ALTER TABLE customer ADD COLUMN icc_code`(幂等)+ 唯一索引
|
||||||
|
- [x] 同步更新 `database/db_init.sql` 中 customer 建表结构(含 icc_code 与唯一索引),保持幂等一致
|
||||||
|
- [x] Task 2: customer 实体新增 iccCode 字段
|
||||||
|
- [x] `server/src/entity/customer.entity.ts` 增加 `iccCode`(STRING(64),field=icc_code,不设 unique 避免与迁移脚本索引重复)
|
||||||
|
- [x] Task 3: ICC OAuth2 鉴权客户端
|
||||||
|
- [x] 封装公钥获取(GET `/evo-apigw/evo-oauth/{version}/oauth/public-key`)
|
||||||
|
- [x] 封装 RSA/ECB/PKCS1 分块加密(Node `crypto.publicEncrypt`,块长 117,Base64 输出,对齐文档 Java 示例)— 已冒烟验证加解密往返一致
|
||||||
|
- [x] 封装 token 获取(POST `/evo-apigw/evo-oauth/{version}/oauth/extend/token`),进程内缓存 access_token,按 expires_in 过期自动续签
|
||||||
|
- [x] Task 4: ICC 客户字段映射器
|
||||||
|
- [x] `server/src/task/icc_customer_mapper.ts`:集中配置接口端点、org_base 过滤条件、字段映射(orgCode→iccCode、orgName→customerName、province/city/county)、PageVO 解析 — 修复读取 bug 后冒烟验证通过
|
||||||
|
- [x] Task 5: IccCustomerSyncService 同步服务
|
||||||
|
- [x] `server/src/service/icc_customer_sync.service.ts`:读取 `.env` 配置(缺失视为失败仅记日志)
|
||||||
|
- [x] 分页全量拉取(maxRangeId=0,pageSize=1000,循环至末页),过滤 orgType=org_base
|
||||||
|
- [x] 预取现有客户 icc_code 映射,按 iccCode 幂等 upsert:先比较 customer_name/province/city/district 四个映射字段,仅任一字段不一致才 update,一致则跳过;新增客户复用 CustomerService.generateCustomerNo(已改 public,KH+YYYYMMDD+3位序号);其余字段(status/salesman_id/联系方式等)不动
|
||||||
|
- [x] 每次同步写 `icc_sync_log`(sync_type=customer,status/record_count/error_message);失败仅记录不抛出
|
||||||
|
- [x] Task 6: 定时任务注册与调度器接线
|
||||||
|
- [x] `server/src/task/icc_customer_sync.task.ts`:jobKey=`icc_customer_sync`,默认 cron `*/10 * * * *`
|
||||||
|
- [x] `server/src/task/job_registry.ts`:注册到 BUILTIN_JOBS,JobRuntime 增加 syncIccCustomers
|
||||||
|
- [x] `server/src/service/job_scheduler.service.ts`:buildRuntime 增加 `syncIccCustomers`,经匿名上下文注入 IccCustomerSyncService
|
||||||
|
- [x] `database/db_seed.sql` 新增 sys_job 种子(job_key=icc_customer_sync,按 job_key 幂等)
|
||||||
|
- [x] Task 7: .env 配置
|
||||||
|
- [x] `server/.env` 增加 ICC_BASE_URL / ICC_VERSION / ICC_CLIENT_ID / ICC_CLIENT_SECRET / ICC_USERNAME / ICC_PASSWORD(凭据取自接口文档)
|
||||||
|
- [x] Task 8: 编译与运行验证
|
||||||
|
- [x] `server` 目录执行 `yarn build` 通过(修复 mapper 后复验通过)
|
||||||
|
- [x] 冒烟验证:RSA 分块加解密往返一致、映射器字段映射与分页解析正确(无副作用,未连库);端到端写库同步需在部署环境首次执行时由真实 ICC 接口 + 数据库实际确认
|
||||||
|
|
||||||
|
# Task Dependencies
|
||||||
|
- [Task 1] 无依赖,可先行
|
||||||
|
- [Task 2] 依赖 [Task 1](字段定义与库结构一致)
|
||||||
|
- [Task 3] 无依赖,可与 [Task 1] 并行
|
||||||
|
- [Task 4] 依赖 [Task 3](需确认响应结构/鉴权头),也可并行开发
|
||||||
|
- [Task 5] 依赖 [Task 1][Task 2][Task 3][Task 4]
|
||||||
|
- [Task 6] 依赖 [Task 5]
|
||||||
|
- [Task 7] 依赖 [Task 3](凭据键名一致),可并行
|
||||||
|
- [Task 8] 依赖全部前置任务
|
||||||
@@ -86,6 +86,8 @@ export const customerApi = {
|
|||||||
|
|
||||||
getCustomerStats: () => request.get<CustomerStats>('/customers/stats'),
|
getCustomerStats: () => request.get<CustomerStats>('/customers/stats'),
|
||||||
|
|
||||||
|
syncIccCustomers: () => request.post('/customers/sync/icc'),
|
||||||
|
|
||||||
createCustomer: (data: CustomerCreateParams) =>
|
createCustomer: (data: CustomerCreateParams) =>
|
||||||
request.post('/customers', data),
|
request.post('/customers', data),
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
<p class="desc">统一维护客户的基础资料、联系信息与业务阶段,支持快速检索与档案管理。</p>
|
<p class="desc">统一维护客户的基础资料、联系信息与业务阶段,支持快速检索与档案管理。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<a-button :loading="syncing" @click="handleSync">
|
||||||
|
<template #icon><SyncOutlined /></template>
|
||||||
|
同步客户
|
||||||
|
</a-button>
|
||||||
<a-button type="primary" @click="handleAdd">
|
<a-button type="primary" @click="handleAdd">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
新增客户
|
新增客户
|
||||||
@@ -323,13 +327,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, computed } from 'vue';
|
import { ref, reactive, onMounted, computed } from 'vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message, Modal } from 'ant-design-vue';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
SyncOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
AimOutlined,
|
AimOutlined,
|
||||||
FileProtectOutlined,
|
FileProtectOutlined,
|
||||||
@@ -352,6 +357,7 @@ const permissionStore = usePermissionStore();
|
|||||||
|
|
||||||
// ==================== 数据 ====================
|
// ==================== 数据 ====================
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const syncing = ref(false);
|
||||||
const dataSource = ref<CustomerInfo[]>([]);
|
const dataSource = ref<CustomerInfo[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const editNo = ref<string>('');
|
const editNo = ref<string>('');
|
||||||
@@ -568,6 +574,38 @@ function getSalesmanName(id?: number) {
|
|||||||
return found?.label ?? '-';
|
return found?.label ?? '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== ICC 手动同步 ====================
|
||||||
|
function handleSync() {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '同步客户',
|
||||||
|
content: '将从 ICC 平台拉取客户信息,并按 ICC 客户编号幂等新增/更新本地客户档案,是否继续?',
|
||||||
|
okText: '开始同步',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
syncing.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await customerApi.syncIccCustomers();
|
||||||
|
if (res.code === 200) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const pulled = d.pulled ?? 0;
|
||||||
|
const inserted = d.inserted ?? 0;
|
||||||
|
const updated = d.updated ?? 0;
|
||||||
|
const skipped = d.skipped ?? 0;
|
||||||
|
message.success(`同步完成:拉取 ${pulled} 条,新增 ${inserted} 条,更新 ${updated} 条,跳过 ${skipped} 条`);
|
||||||
|
fetchList();
|
||||||
|
fetchStats();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '同步失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('同步失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
syncing.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 搜索重置 ====================
|
// ==================== 搜索重置 ====================
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
searchForm.page = 1;
|
searchForm.page = 1;
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ DROP TABLE IF EXISTS customer CASCADE;
|
|||||||
CREATE TABLE customer (
|
CREATE TABLE customer (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
customer_no VARCHAR(64) NOT NULL UNIQUE,
|
customer_no VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
icc_code VARCHAR(64),
|
||||||
customer_name VARCHAR(128) NOT NULL,
|
customer_name VARCHAR(128) NOT NULL,
|
||||||
province VARCHAR(64),
|
province VARCHAR(64),
|
||||||
city VARCHAR(64),
|
city VARCHAR(64),
|
||||||
@@ -209,6 +210,7 @@ CREATE TABLE customer (
|
|||||||
COMMENT ON TABLE customer IS '客户信息表';
|
COMMENT ON TABLE customer IS '客户信息表';
|
||||||
COMMENT ON COLUMN customer.id IS '自增主键';
|
COMMENT ON COLUMN customer.id IS '自增主键';
|
||||||
COMMENT ON COLUMN customer.customer_no IS '客户业务编号(KH+YYYYMMDD+3位序号)';
|
COMMENT ON COLUMN customer.customer_no IS '客户业务编号(KH+YYYYMMDD+3位序号)';
|
||||||
|
COMMENT ON COLUMN customer.icc_code IS 'ICC客户编码(ICC平台 orgCode,同步来源)';
|
||||||
COMMENT ON COLUMN customer.customer_name IS '客户名称';
|
COMMENT ON COLUMN customer.customer_name IS '客户名称';
|
||||||
COMMENT ON COLUMN customer.province IS '省';
|
COMMENT ON COLUMN customer.province IS '省';
|
||||||
COMMENT ON COLUMN customer.city IS '市';
|
COMMENT ON COLUMN customer.city IS '市';
|
||||||
@@ -227,6 +229,7 @@ CREATE INDEX idx_customer_name ON customer (customer_name);
|
|||||||
CREATE INDEX idx_customer_salesman ON customer (salesman_id);
|
CREATE INDEX idx_customer_salesman ON customer (salesman_id);
|
||||||
CREATE INDEX idx_customer_status ON customer (status);
|
CREATE INDEX idx_customer_status ON customer (status);
|
||||||
CREATE INDEX idx_customer_region ON customer (province, city, district);
|
CREATE INDEX idx_customer_region ON customer (province, city, district);
|
||||||
|
CREATE UNIQUE INDEX uk_customer_icc_code ON customer (icc_code);
|
||||||
CREATE TRIGGER trg_customer_updated_at BEFORE UPDATE ON customer
|
CREATE TRIGGER trg_customer_updated_at BEFORE UPDATE ON customer
|
||||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||||
|
|
||||||
|
|||||||
@@ -404,6 +404,14 @@ INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, re
|
|||||||
'从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(默认每 10 分钟)')
|
'从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(默认每 10 分钟)')
|
||||||
ON CONFLICT (job_key) DO NOTHING;
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 10b. 客户信息同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
||||||
|
('icc_customer_sync', '客户信息同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
|
||||||
|
'从 ICC 平台拉取 org_base 组织数据并按 iccCode 幂等新增/变更客户(默认每 10 分钟)')
|
||||||
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 11. 通知规则种子(sys_notice_rule,按 rule_key 幂等;接收对象可在「通知管理」页调整)
|
-- 11. 通知规则种子(sys_notice_rule,按 rule_key 幂等;接收对象可在「通知管理」页调整)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- ICC 客户信息同步(第一阶段):
|
||||||
|
-- 1) 幂等新增 customer.icc_code 列(ICC 平台 orgCode,作为 ICC 同步的幂等匹配键)
|
||||||
|
-- 2) 幂等创建唯一索引 uk_customer_icc_code(icc_code 可空,PostgreSQL 下多个 NULL 不冲突)
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 1) 新增列 icc_code(可空,幂等;与 db_init.sql / customer.entity.ts 保持一致)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
ALTER TABLE customer ADD COLUMN IF NOT EXISTS icc_code VARCHAR(64);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 2) 唯一索引(icc_code 可空,PostgreSQL 中多个 NULL 不冲突,唯一索引安全)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_customer_icc_code ON customer (icc_code);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 3) 列注释
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
COMMENT ON COLUMN customer.icc_code IS 'ICC客户编码(ICC平台 orgCode,同步来源)';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
+7
-2
@@ -23,6 +23,11 @@ REDIS_HOST=222.243.55.42
|
|||||||
REDIS_PORT=26678
|
REDIS_PORT=26678
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
# ICC 平台配置(结算所述智电改造线路数据由 ICC 同步,占位;接入真实平台时填写)
|
# ICC 平台配置(智电线路同步沿用 ICC_BASE_URL / ICC_TOKEN;客户信息同步走 OAuth2 密码模式)
|
||||||
ICC_BASE_URL=
|
ICC_BASE_URL=https://222.243.55.92:1443
|
||||||
ICC_TOKEN=
|
ICC_TOKEN=
|
||||||
|
ICC_VERSION=1.0.0
|
||||||
|
ICC_CLIENT_ID=xtzdapp
|
||||||
|
ICC_CLIENT_SECRET=1c7e440a-660c-4a5d-a7b8-82b58d9dca4e
|
||||||
|
ICC_USERNAME=XTZD接口
|
||||||
|
ICC_PASSWORD=Xtzdkj@20260801
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.5",
|
||||||
"sequelize-typescript": "^2.1.6",
|
"sequelize-typescript": "^2.1.6",
|
||||||
|
"undici": "^8.12.2",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ if (existsSync(envFile)) {
|
|||||||
loadEnv({ path: envFile, override: true });
|
loadEnv({ path: envFile, override: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// midway logger 默认日志目录为 join(MIDWAY_LOGGER_WRITEABLE_DIR ?? appInfo.root, "logs", appName),
|
||||||
|
// 且启动时 loggerService 会用该默认值强制覆盖用户 logger.dir 配置(addObject reverse=true)。
|
||||||
|
// 显式指定项目目录,使日志最终落在 server/logs 下,避免写入用户主目录触发 EPERM 权限报错。
|
||||||
|
if (!process.env.MIDWAY_LOGGER_WRITEABLE_DIR) {
|
||||||
|
process.env.MIDWAY_LOGGER_WRITEABLE_DIR = envDir;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
keys: "smart-grid-iot-secret-key",
|
keys: "smart-grid-iot-secret-key",
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||||
import { Context } from "@midwayjs/koa";
|
import { Context } from "@midwayjs/koa";
|
||||||
import { CustomerService } from "../service/customer.service";
|
import { CustomerService } from "../service/customer.service";
|
||||||
|
import { IccCustomerSyncService } from "../service/icc_customer_sync.service";
|
||||||
|
|
||||||
@Controller("/api/customers")
|
@Controller("/api/customers")
|
||||||
export class CustomerController {
|
export class CustomerController {
|
||||||
@@ -10,6 +11,9 @@ export class CustomerController {
|
|||||||
@Inject()
|
@Inject()
|
||||||
customerService!: CustomerService;
|
customerService!: CustomerService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
iccCustomerSyncService!: IccCustomerSyncService;
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
async list(@Query() query: any) {
|
async list(@Query() query: any) {
|
||||||
const result = await this.customerService.list(query, this.ctx);
|
const result = await this.customerService.list(query, this.ctx);
|
||||||
@@ -28,6 +32,16 @@ export class CustomerController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("/sync/icc")
|
||||||
|
async syncIccCustomers() {
|
||||||
|
const result = await this.iccCustomerSyncService.syncFromIcc(this.ctx);
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "同步完成",
|
||||||
|
data: result
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Get("/:id")
|
@Get("/:id")
|
||||||
async detail(@Param("id") id: string) {
|
async detail(@Param("id") id: string) {
|
||||||
const result = await this.customerService.detail(Number(id), this.ctx);
|
const result = await this.customerService.detail(Number(id), this.ctx);
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ export default class Customer extends Model {
|
|||||||
})
|
})
|
||||||
customerNo!: string;
|
customerNo!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING(64),
|
||||||
|
field: "icc_code",
|
||||||
|
comment: "ICC客户编码(ICC平台 orgCode,同步来源)"
|
||||||
|
})
|
||||||
|
iccCode!: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: DataType.STRING(128),
|
type: DataType.STRING(128),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export class CustomerService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateCustomerNo(retry: number = 0): Promise<string> {
|
public async generateCustomerNo(retry: number = 0): Promise<string> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const offsetMs = now.getTime() + 8 * 60 * 60 * 1000;
|
const offsetMs = now.getTime() + 8 * 60 * 60 * 1000;
|
||||||
const dateLocal = new Date(offsetMs);
|
const dateLocal = new Date(offsetMs);
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { Provide, App } from "@midwayjs/core";
|
||||||
|
import { InjectRepository } from "@midwayjs/sequelize";
|
||||||
|
import { Repository } from "sequelize-typescript";
|
||||||
|
import { Op, UniqueConstraintError } from "sequelize";
|
||||||
|
import { Context, Application } from "@midwayjs/koa";
|
||||||
|
import Customer from "../entity/customer.entity";
|
||||||
|
import IccSyncLog from "../entity/icc_sync_log.entity";
|
||||||
|
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
|
||||||
|
import { ICC_ORG_ENDPOINT, ICC_ORG_TYPE_BASE, mapIccOrgRecord, parseOrgPage, IccOrgRawRecord } from "../task/icc_customer_mapper";
|
||||||
|
import { CustomerService } from "./customer.service";
|
||||||
|
|
||||||
|
export interface IccCustomerSyncResult {
|
||||||
|
/** ICC 拉取到的原始组织条数(含非 org_base) */
|
||||||
|
pulled: number;
|
||||||
|
/** 成功落库的客户条数(新增+更新) */
|
||||||
|
synced: number;
|
||||||
|
/** 新增的客户条数 */
|
||||||
|
inserted: number;
|
||||||
|
/** 更新的客户条数 */
|
||||||
|
updated: number;
|
||||||
|
/** 跳过的条数(非 org_base / 缺 iccCode / 缺客户名 / 四字段无变化 / 编号冲突无法解决) */
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYNC_TYPE = "customer";
|
||||||
|
/** 分页拉取参数(ICC 组织分页接口约定) */
|
||||||
|
const PAGE_SIZE = 1000;
|
||||||
|
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
|
||||||
|
const DEFAULT_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
/** mapIccOrgRecord 的返回类型别名 */
|
||||||
|
type MappedOrg = ReturnType<typeof mapIccOrgRecord>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICC 客户信息拉取同步:
|
||||||
|
* 1) 从 .env 读取 ICC_BASE_URL / ICC_VERSION,经 getIccAccessToken 走 OAuth2 密码模式鉴权
|
||||||
|
* 2) 分页请求 ICC 组织列表(/organization/page,pageNum/pageSize/maxRangeId)
|
||||||
|
* 3) 仅保留 orgType=org_base 的组织(对应客户),按 iccCode 幂等 upsert
|
||||||
|
* 4) 每次同步写一条 icc_sync_log;拉取失败仅记录 error,不抛业务错误
|
||||||
|
*
|
||||||
|
* 客户编号生成:复用 CustomerService.generateCustomerNo(已由 private 改为 public,
|
||||||
|
* 保持与手工建客户完全一致的 KH+YYYYMMDD+3位序号 规则),经匿名请求上下文获取实例。
|
||||||
|
*/
|
||||||
|
@Provide()
|
||||||
|
export class IccCustomerSyncService {
|
||||||
|
@App()
|
||||||
|
app!: Application;
|
||||||
|
|
||||||
|
@InjectRepository(Customer)
|
||||||
|
customerModel!: Repository<Customer>;
|
||||||
|
|
||||||
|
@InjectRepository(IccSyncLog)
|
||||||
|
syncLogModel!: Repository<IccSyncLog>;
|
||||||
|
|
||||||
|
async syncFromIcc(ctx?: Context): Promise<IccCustomerSyncResult> {
|
||||||
|
const startTime = new Date();
|
||||||
|
const baseUrl = process.env.ICC_BASE_URL?.trim();
|
||||||
|
|
||||||
|
const result: IccCustomerSyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
|
||||||
|
|
||||||
|
// 基础配置缺失视为拉取失败:记录日志,不抛业务错误
|
||||||
|
if (!baseUrl) {
|
||||||
|
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getIccAccessToken();
|
||||||
|
const customerService = await this.getCustomerService();
|
||||||
|
const url = `${baseUrl.replace(/\/$/, "")}${ICC_ORG_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
|
||||||
|
|
||||||
|
const allRaw: IccOrgRawRecord[] = [];
|
||||||
|
let pageNum = 1;
|
||||||
|
let maxRangeId = 0;
|
||||||
|
let currentPage = 1;
|
||||||
|
let totalPage = 1;
|
||||||
|
|
||||||
|
while (pageNum <= totalPage) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
pageNum: String(pageNum),
|
||||||
|
pageSize: String(PAGE_SIZE),
|
||||||
|
maxRangeId: String(maxRangeId)
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await iccFetch(`${url}?${params.toString()}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json", Authorization: `bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(60000) // 按项目约定 HTTP 超时 60s
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`ICC 组织接口返回异常:HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: any = await res.json();
|
||||||
|
const page = parseOrgPage(payload);
|
||||||
|
allRaw.push(...page.list);
|
||||||
|
result.pulled += page.list.length;
|
||||||
|
currentPage = page.currentPage;
|
||||||
|
totalPage = page.totalPage;
|
||||||
|
|
||||||
|
// currentPage 达到 totalPage 或返回空列表即停止
|
||||||
|
if (page.list.length === 0 || currentPage >= totalPage) break;
|
||||||
|
pageNum++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { inserted, updated, skipped } = await this.batchUpsert(allRaw, customerService);
|
||||||
|
result.inserted = inserted;
|
||||||
|
result.updated = updated;
|
||||||
|
result.synced = inserted + updated;
|
||||||
|
result.skipped = skipped;
|
||||||
|
|
||||||
|
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
|
||||||
|
return result;
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从匿名请求上下文获取请求作用域的 CustomerService(参照 job_scheduler.buildRuntime) */
|
||||||
|
private async getCustomerService(): Promise<CustomerService> {
|
||||||
|
const ctx = this.app.createAnonymousContext();
|
||||||
|
return (await ctx.requestContext.getAsync(CustomerService)) as CustomerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建行政区划字典:org_province/org_city/org_county 的 编码字段 → orgName(名称)。 */
|
||||||
|
private buildRegionDict(allRaw: IccOrgRawRecord[]): {
|
||||||
|
provinceDict: Record<string, string>;
|
||||||
|
cityDict: Record<string, string>;
|
||||||
|
countyDict: Record<string, string>;
|
||||||
|
} {
|
||||||
|
const provinceDict: Record<string, string> = {};
|
||||||
|
const cityDict: Record<string, string> = {};
|
||||||
|
const countyDict: Record<string, string> = {};
|
||||||
|
for (const raw of allRaw) {
|
||||||
|
const orgType = String(raw.orgType ?? "");
|
||||||
|
const orgName = String(raw.orgName ?? "").trim();
|
||||||
|
if (!orgName) continue;
|
||||||
|
if (orgType === "org_province" && raw.province) {
|
||||||
|
provinceDict[String(raw.province).trim()] = orgName;
|
||||||
|
} else if (orgType === "org_city" && raw.city) {
|
||||||
|
cityDict[String(raw.city).trim()] = orgName;
|
||||||
|
} else if (orgType === "org_county" && raw.county) {
|
||||||
|
countyDict[String(raw.county).trim()] = orgName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { provinceDict, cityDict, countyDict };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 iccCode 幂等 upsert(新增/变更四字段:客户名、省、市、区)。 */
|
||||||
|
private async batchUpsert(allRaw: IccOrgRawRecord[], customerService: CustomerService): Promise<{ inserted: number; updated: number; skipped: number }> {
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
// 从全量组织树构建行政区划字典(ICC 的 org_base 记录 province/city/county 为
|
||||||
|
// 国标行政区划编码,客户表存名称;org_province/org_city/org_county 记录的
|
||||||
|
// province/city/county 编码字段 + orgName 即为 编码→名称 映射)
|
||||||
|
const { provinceDict, cityDict, countyDict } = this.buildRegionDict(allRaw);
|
||||||
|
|
||||||
|
// 仅保留 org_base 组织(客户);组织类型缺失或非 org_base 均跳过,避免误同步非客户组织
|
||||||
|
const mappedList: MappedOrg[] = [];
|
||||||
|
for (const raw of allRaw) {
|
||||||
|
const mapped = mapIccOrgRecord(raw);
|
||||||
|
if (mapped.orgType !== ICC_ORG_TYPE_BASE) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 行政区划编码 → 名称;字典未命中时保留编码原值
|
||||||
|
if (mapped.province && provinceDict[mapped.province]) mapped.province = provinceDict[mapped.province];
|
||||||
|
if (mapped.city && cityDict[mapped.city]) mapped.city = cityDict[mapped.city];
|
||||||
|
if (mapped.district && countyDict[mapped.district]) mapped.district = countyDict[mapped.district];
|
||||||
|
mappedList.push(mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预取已存在客户(按 iccCode),避免 N+1;需四字段现值用于变更比较
|
||||||
|
const iccCodes = [...new Set(mappedList.map((m) => m.iccCode).filter((c) => c !== ""))];
|
||||||
|
const existingRows = iccCodes.length
|
||||||
|
? await this.customerModel.findAll({
|
||||||
|
where: { iccCode: { [Op.in]: iccCodes } },
|
||||||
|
attributes: ["id", "iccCode", "customerName", "province", "city", "district"]
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const customerByIcc = new Map(existingRows.map((c) => [c.iccCode, c]));
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const mapped of mappedList) {
|
||||||
|
if (!mapped.iccCode) {
|
||||||
|
skipped++; // iccCode 缺失,无同步匹配键
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!mapped.customerName) {
|
||||||
|
skipped++; // 客户名必填(customer.customer_name NOT NULL),缺失跳过避免脏数据
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = customerByIcc.get(mapped.iccCode);
|
||||||
|
if (existing) {
|
||||||
|
if (this.hasFieldDiff(existing, mapped)) {
|
||||||
|
await existing.update({
|
||||||
|
customerName: mapped.customerName,
|
||||||
|
province: mapped.province ?? null,
|
||||||
|
city: mapped.city ?? null,
|
||||||
|
district: mapped.district ?? null
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
skipped++; // 四字段均无变化,不重复更新
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (await this.createCustomer(mapped, customerService)) {
|
||||||
|
inserted++;
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inserted, updated, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 四字段变更比较:字符串 trim 后比较,undefined/null 视为空串。 */
|
||||||
|
private hasFieldDiff(existing: Customer, mapped: MappedOrg): boolean {
|
||||||
|
const norm = (v: string | null | undefined): string => (v ?? "").trim();
|
||||||
|
return (
|
||||||
|
norm(existing.customerName) !== norm(mapped.customerName) ||
|
||||||
|
norm(existing.province) !== norm(mapped.province) ||
|
||||||
|
norm(existing.city) !== norm(mapped.city) ||
|
||||||
|
norm(existing.district) !== norm(mapped.district)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增客户:编号走 CustomerService.generateCustomerNo(KH+YYYYMMDD+3位序号)。
|
||||||
|
* 编号唯一冲突(UniqueConstraintError)时重试一次;仍失败返回 false 由调用方计 skipped。
|
||||||
|
* 其余异常上抛,由外层 catch 记录同步失败。
|
||||||
|
*/
|
||||||
|
private async createCustomer(mapped: MappedOrg, customerService: CustomerService): Promise<boolean> {
|
||||||
|
const payload: any = {
|
||||||
|
customerName: mapped.customerName,
|
||||||
|
province: mapped.province ?? null,
|
||||||
|
city: mapped.city ?? null,
|
||||||
|
district: mapped.district ?? null,
|
||||||
|
iccCode: mapped.iccCode,
|
||||||
|
status: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
payload.customerNo = await customerService.generateCustomerNo();
|
||||||
|
await this.customerModel.create(payload);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof UniqueConstraintError && err.errors?.some((e) => e.path === "customer_no" || e.path === "uk_customer_no")) {
|
||||||
|
try {
|
||||||
|
payload.customerNo = await customerService.generateCustomerNo();
|
||||||
|
await this.customerModel.create(payload);
|
||||||
|
return true;
|
||||||
|
} catch (_e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.syncLogModel.create({
|
||||||
|
syncType,
|
||||||
|
startTime,
|
||||||
|
endTime: new Date(),
|
||||||
|
recordCount,
|
||||||
|
status,
|
||||||
|
errorMessage
|
||||||
|
} as any);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ICC同步] 同步日志写入失败:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import SysJobLog from "../entity/sys_job_log.entity";
|
|||||||
import { LogService } from "./log.service";
|
import { LogService } from "./log.service";
|
||||||
import { NoticeRuleService } from "./notice_rule.service";
|
import { NoticeRuleService } from "./notice_rule.service";
|
||||||
import { IccLineSyncService } from "./icc_line_sync.service";
|
import { IccLineSyncService } from "./icc_line_sync.service";
|
||||||
|
import { IccCustomerSyncService } from "./icc_customer_sync.service";
|
||||||
import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
|
import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
|
||||||
|
|
||||||
/** 任务执行结果(一次尝试) */
|
/** 任务执行结果(一次尝试) */
|
||||||
@@ -452,6 +453,12 @@ export class JobSchedulerService {
|
|||||||
const iccService = (await ctx.requestContext.getAsync(IccLineSyncService)) as IccLineSyncService;
|
const iccService = (await ctx.requestContext.getAsync(IccLineSyncService)) as IccLineSyncService;
|
||||||
const result = await iccService.syncFromIcc();
|
const result = await iccService.syncFromIcc();
|
||||||
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
|
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
|
||||||
|
},
|
||||||
|
syncIccCustomers: async () => {
|
||||||
|
const ctx = this.app.createAnonymousContext();
|
||||||
|
const iccCustomerService = (await ctx.requestContext.getAsync(IccCustomerSyncService)) as IccCustomerSyncService;
|
||||||
|
const result = await iccCustomerService.syncFromIcc();
|
||||||
|
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* ICC 平台(大华 ICC)OAuth2 密码模式鉴权客户端。
|
||||||
|
*
|
||||||
|
* 流程(对齐鑫潼 ICC 平台接口文档,host 形如 222.243.55.92:1443):
|
||||||
|
* 1) GET /evo-apigw/evo-oauth/{version}/oauth/public-key 获取公钥(base64 编码的 X509 SPKI DER)
|
||||||
|
* 2) RSA/ECB/PKCS1Padding 分块加密密码(每块 ≤117 字节),分块密文 Base64 拼接
|
||||||
|
* 3) POST /evo-apigw/evo-oauth/{version}/oauth/extend/token 换取 access_token
|
||||||
|
* 4) 进程内缓存 token,过期前 60 秒视为失效并自动重新鉴权
|
||||||
|
*
|
||||||
|
* 配置项(server/.env,均为必填,ICC_VERSION 缺省 1.0.0):
|
||||||
|
* ICC_BASE_URL / ICC_VERSION / ICC_CLIENT_ID / ICC_CLIENT_SECRET / ICC_USERNAME / ICC_PASSWORD
|
||||||
|
*/
|
||||||
|
import { createPublicKey, publicEncrypt, constants } from "node:crypto";
|
||||||
|
import { fetch as undiciFetch, Agent } from "undici";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICC 平台的 HTTPS 证书为非公开受信(自签名)证书,Node 默认 TLS 校验会拒绝。
|
||||||
|
* 这里为 ICC 请求单独创建关闭证书校验的 Agent(仅作用于 ICC 请求,不影响系统其他 HTTPS 连接)。
|
||||||
|
* 注意:Node 全局 fetch 不支持外部 undici Agent dispatcher(协议不兼容),故统一使用 undici fetch 封装。
|
||||||
|
*/
|
||||||
|
let iccAgent: Agent | null = null;
|
||||||
|
function getIccAgent(): Agent {
|
||||||
|
if (!iccAgent) {
|
||||||
|
iccAgent = new Agent({ connect: { rejectUnauthorized: false } });
|
||||||
|
}
|
||||||
|
return iccAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 带 ICC 专用 Agent 的 fetch:自动附加 dispatcher,调用方无需再传。 */
|
||||||
|
export function iccFetch(input: any, init?: any): ReturnType<typeof undiciFetch> {
|
||||||
|
return undiciFetch(input, { ...init, dispatcher: getIccAgent() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 鉴权结果:accessToken 与本地过期时间戳(毫秒) */
|
||||||
|
export interface IccAuthResult {
|
||||||
|
accessToken: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取公钥接口路径({version} 运行时替换) */
|
||||||
|
const PUBLIC_KEY_ENDPOINT = "/evo-apigw/evo-oauth/{version}/oauth/public-key";
|
||||||
|
/** 换取 token 接口路径({version} 运行时替换) */
|
||||||
|
const TOKEN_ENDPOINT = "/evo-apigw/evo-oauth/{version}/oauth/extend/token";
|
||||||
|
|
||||||
|
/** 默认 version(.env 未配置 ICC_VERSION 时使用) */
|
||||||
|
const DEFAULT_VERSION = "1.0.0";
|
||||||
|
/** RSA/ECB/PKCS1Padding 单块最大明文长度(字节) */
|
||||||
|
const RSA_MAX_CHUNK_BYTES = 117;
|
||||||
|
/** token 过期前多少毫秒视为失效并自动续签 */
|
||||||
|
const REFRESH_LEAD_TIME_MS = 60 * 1000;
|
||||||
|
/** 响应未返回 expires_in 时的默认有效期(毫秒) */
|
||||||
|
const DEFAULT_EXPIRES_IN_MS = 7200 * 1000;
|
||||||
|
/** HTTP 超时 60s(项目约定) */
|
||||||
|
const HTTP_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
/** 换取 token 的请求体(对应 ICC extend/token 接口字段名) */
|
||||||
|
interface TokenRequestBody {
|
||||||
|
grant_type: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
client_id: string;
|
||||||
|
client_secret: string;
|
||||||
|
public_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进程内 token 缓存 */
|
||||||
|
let cachedAuth: IccAuthResult | null = null;
|
||||||
|
|
||||||
|
/** 读取必填配置,缺失时抛出明确提示(指明缺哪个配置键)。 */
|
||||||
|
function requireConfig(key: string): string {
|
||||||
|
const value = process.env[key]?.trim();
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`缺少 ICC 鉴权必需配置 ${key},请检查 server/.env`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拼接 baseUrl(去末尾斜杠)与接口路径,并替换 {version} 占位。 */
|
||||||
|
function buildUrl(pathTemplate: string): string {
|
||||||
|
const baseUrl = requireConfig("ICC_BASE_URL").replace(/\/$/, "");
|
||||||
|
const version = process.env.ICC_VERSION?.trim() || DEFAULT_VERSION;
|
||||||
|
return `${baseUrl}${pathTemplate.replace("{version}", version)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取公钥:返回 base64 编码的 X509 SPKI DER 公钥原文。 */
|
||||||
|
async function fetchPublicKey(): Promise<string> {
|
||||||
|
const res = await iccFetch(buildUrl(PUBLIC_KEY_ENDPOINT), {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`获取 ICC 公钥失败:HTTP ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
const payload: any = await res.json();
|
||||||
|
if (String(payload?.code ?? "") !== "0" || !payload?.data) {
|
||||||
|
throw new Error(`获取 ICC 公钥失败:${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
// ICC 返回结构为 { data: { publicKey: "<base64 编码的 X509 SPKI DER 公钥>" } }
|
||||||
|
return String(payload.data.publicKey ?? payload.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSA 分块加密密码(对齐文档 Java 示例:Cipher.getInstance("RSA") 即
|
||||||
|
* RSA/ECB/PKCS1Padding,单块最大明文 117 字节),分块密文拼接后整体 Base64。
|
||||||
|
* 防御:明文为空时直接返回空字符串。
|
||||||
|
*/
|
||||||
|
function rsaEncryptPassword(password: string, publicKeyB64: string): string {
|
||||||
|
const input = Buffer.from(password, "utf8");
|
||||||
|
if (input.length === 0) return "";
|
||||||
|
|
||||||
|
// ICC 返回的 publicKey 为 base64 编码的 X509 SPKI DER 公钥,故 type 用 "spki"
|
||||||
|
const key = createPublicKey({
|
||||||
|
key: Buffer.from(publicKeyB64, "base64"),
|
||||||
|
format: "der",
|
||||||
|
type: "spki"
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for (let i = 0; i < input.length; i += RSA_MAX_CHUNK_BYTES) {
|
||||||
|
const chunk = input.subarray(i, i + RSA_MAX_CHUNK_BYTES);
|
||||||
|
chunks.push(publicEncrypt({ key, padding: constants.RSA_PKCS1_PADDING }, chunk));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用 RSA 加密后的密码换取 access_token(data.expires_in 单位秒)。 */
|
||||||
|
async function fetchToken(body: TokenRequestBody): Promise<IccAuthResult> {
|
||||||
|
const res = await iccFetch(buildUrl(TOKEN_ENDPOINT), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`换取 ICC token 失败:HTTP ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
const payload: any = await res.json();
|
||||||
|
if (String(payload?.code ?? "") !== "0" || !payload?.data?.access_token) {
|
||||||
|
throw new Error(`换取 ICC token 失败:${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
const expiresIn = Number(payload.data.expires_in);
|
||||||
|
const expiresAt =
|
||||||
|
Date.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1000 : DEFAULT_EXPIRES_IN_MS);
|
||||||
|
return { accessToken: String(payload.data.access_token), expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 ICC access_token(进程内缓存,过期前 60 秒自动重新鉴权)。
|
||||||
|
* 配置缺失或鉴权失败时抛 Error,由调用方(同步服务)决定如何处理。
|
||||||
|
*/
|
||||||
|
export async function getIccAccessToken(): Promise<string> {
|
||||||
|
// 一次性校验全部必填配置,缺失时明确提示
|
||||||
|
requireConfig("ICC_BASE_URL");
|
||||||
|
requireConfig("ICC_CLIENT_ID");
|
||||||
|
requireConfig("ICC_CLIENT_SECRET");
|
||||||
|
requireConfig("ICC_USERNAME");
|
||||||
|
requireConfig("ICC_PASSWORD");
|
||||||
|
|
||||||
|
if (cachedAuth && cachedAuth.expiresAt - Date.now() > REFRESH_LEAD_TIME_MS) {
|
||||||
|
return cachedAuth.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = await fetchPublicKey();
|
||||||
|
const auth = await fetchToken({
|
||||||
|
grant_type: "password",
|
||||||
|
username: requireConfig("ICC_USERNAME"),
|
||||||
|
password: rsaEncryptPassword(requireConfig("ICC_PASSWORD"), publicKey),
|
||||||
|
client_id: requireConfig("ICC_CLIENT_ID"),
|
||||||
|
client_secret: requireConfig("ICC_CLIENT_SECRET"),
|
||||||
|
public_key: publicKey
|
||||||
|
});
|
||||||
|
|
||||||
|
cachedAuth = auth;
|
||||||
|
console.log(`[ICC鉴权] access_token 获取成功,有效期至 ${new Date(auth.expiresAt).toISOString()}`);
|
||||||
|
return auth.accessToken;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* ICC 客户(组织)数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
|
||||||
|
*
|
||||||
|
* ICC 返回的原始字段名 → 本系统 customer 字段。接入真实 ICC 平台时仅需
|
||||||
|
* 调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
|
||||||
|
*/
|
||||||
|
export interface IccOrgRawRecord {
|
||||||
|
[field: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组织分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
|
||||||
|
export const ICC_ORG_ENDPOINT = "/evo-apigw/evo-brm/{version}/organization/page";
|
||||||
|
|
||||||
|
/** 分页请求参数名约定:pageNum / pageSize / maxRangeId(调用方构造请求时使用) */
|
||||||
|
|
||||||
|
/** 组织类型过滤常量:仅同步 base 组织(对应客户) */
|
||||||
|
export const ICC_ORG_TYPE_BASE = "org_base";
|
||||||
|
|
||||||
|
/** 原始记录关键字段映射:ICC 返回字段名 → 本系统 customer 字段 */
|
||||||
|
export const ICC_ORG_FIELDS = {
|
||||||
|
orgCode: "iccCode", // ICC 客户编码(同步匹配键)
|
||||||
|
orgName: "customerName", // 组织名称 → 客户名称
|
||||||
|
province: "province", // 省
|
||||||
|
city: "city", // 市
|
||||||
|
county: "district" // 县/区 → 区县
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 将 ICC 原始组织记录映射为本系统 customer 落库字段(字符串 trim,空串转 undefined)。 */
|
||||||
|
export function mapIccOrgRecord(raw: IccOrgRawRecord): {
|
||||||
|
iccCode: string;
|
||||||
|
customerName: string;
|
||||||
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
district?: string;
|
||||||
|
orgType?: string;
|
||||||
|
orgNameRaw?: string;
|
||||||
|
} {
|
||||||
|
const result: Record<string, any> = {
|
||||||
|
orgType: toOptionalString(raw.orgType), // 组织类型,用于 org_base 过滤判断
|
||||||
|
orgNameRaw: toOptionalString(raw.orgName) // 原始组织名称,便于核对
|
||||||
|
};
|
||||||
|
// 遍历 ICC_ORG_FIELDS(ICC 返回字段名 → 本系统字段名),从 raw 按 ICC 字段名取值
|
||||||
|
for (const [iccField, targetField] of Object.entries(ICC_ORG_FIELDS)) {
|
||||||
|
const value = raw[iccField];
|
||||||
|
result[targetField] =
|
||||||
|
iccField === "orgCode" || iccField === "orgName"
|
||||||
|
? String(value ?? "").trim() // 匹配键/必填名保持空串语义,由调用方判跳
|
||||||
|
: toOptionalString(value);
|
||||||
|
}
|
||||||
|
return result as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字符串 trim;null / undefined / 空串统一归一为 undefined。 */
|
||||||
|
function toOptionalString(value: unknown): string | undefined {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
const s = String(value).trim();
|
||||||
|
return s === "" ? undefined : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析组织分页响应:
|
||||||
|
* - payload 为数组 → 直接作为列表
|
||||||
|
* - payload.data 为 PageVO({ currentPage, totalPage, pageSize, totalRows, pageData })→ 取 pageData
|
||||||
|
* - 结构不匹配或解析异常 → 返回空列表
|
||||||
|
*/
|
||||||
|
export function parseOrgPage(payload: any): {
|
||||||
|
list: IccOrgRawRecord[];
|
||||||
|
currentPage: number;
|
||||||
|
totalPage: number;
|
||||||
|
} {
|
||||||
|
try {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return { list: payload, currentPage: 1, totalPage: 1 };
|
||||||
|
}
|
||||||
|
const page = payload?.data;
|
||||||
|
if (page && Array.isArray(page.pageData)) {
|
||||||
|
return {
|
||||||
|
list: page.pageData,
|
||||||
|
currentPage: Number(page.currentPage) || 1,
|
||||||
|
totalPage: Number(page.totalPage) || 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { list: [], currentPage: 1, totalPage: 1 };
|
||||||
|
} catch {
|
||||||
|
return { list: [], currentPage: 1, totalPage: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { BuiltinJob } from "./job_registry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户信息同步任务:从 ICC 平台拉取 org_base 组织并按 iccCode 幂等新增/变更客户,
|
||||||
|
* 写 icc_sync_log。默认每 10 分钟执行一次。
|
||||||
|
*/
|
||||||
|
export const iccCustomerSyncJob: BuiltinJob = {
|
||||||
|
jobKey: "icc_customer_sync",
|
||||||
|
jobName: "客户信息同步",
|
||||||
|
description: "从 ICC 平台拉取 org_base 组织数据并按 iccCode 幂等新增/变更客户(写 icc_sync_log)",
|
||||||
|
defaultCron: "*/10 * * * *",
|
||||||
|
defaultParams: {},
|
||||||
|
paramFields: [],
|
||||||
|
handler: async (_params, ctx) => {
|
||||||
|
return ctx.runtime.syncIccCustomers();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import { demoHeartbeatJob } from "./demo_heartbeat.task";
|
|||||||
import { logCleanJob } from "./log_clean.task";
|
import { logCleanJob } from "./log_clean.task";
|
||||||
import { noticeScanJob } from "./notice_scan.task";
|
import { noticeScanJob } from "./notice_scan.task";
|
||||||
import { iccLineSyncJob } from "./icc_line_sync.task";
|
import { iccLineSyncJob } from "./icc_line_sync.task";
|
||||||
|
import { iccCustomerSyncJob } from "./icc_customer_sync.task";
|
||||||
|
|
||||||
export type JobParamType = "number" | "string";
|
export type JobParamType = "number" | "string";
|
||||||
|
|
||||||
@@ -32,6 +33,8 @@ export interface JobRuntime {
|
|||||||
runNoticeRules(): Promise<string>;
|
runNoticeRules(): Promise<string>;
|
||||||
/** 从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert,返回执行摘要 */
|
/** 从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert,返回执行摘要 */
|
||||||
syncIccLines(): Promise<string>;
|
syncIccLines(): Promise<string>;
|
||||||
|
/** 从 ICC 平台拉取 org_base 组织并按 iccCode 幂等新增/变更客户,返回执行摘要 */
|
||||||
|
syncIccCustomers(): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobHandlerContext {
|
export interface JobHandlerContext {
|
||||||
@@ -58,7 +61,7 @@ export interface BuiltinJob {
|
|||||||
handler: JobHandler;
|
handler: JobHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob];
|
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob, iccCustomerSyncJob];
|
||||||
|
|
||||||
export function getBuiltinJob(jobKey: string): BuiltinJob | undefined {
|
export function getBuiltinJob(jobKey: string): BuiltinJob | undefined {
|
||||||
return BUILTIN_JOBS.find(job => job.jobKey === jobKey);
|
return BUILTIN_JOBS.find(job => job.jobKey === jobKey);
|
||||||
|
|||||||
@@ -4537,6 +4537,11 @@ undici-types@~6.21.0:
|
|||||||
resolved "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz"
|
resolved "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz"
|
||||||
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
|
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
|
||||||
|
|
||||||
|
undici@^8.12.2:
|
||||||
|
version "8.12.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/undici/-/undici-8.12.2.tgz#e869237430adcc5de6bca798c94edd7db9dac5ac"
|
||||||
|
integrity sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==
|
||||||
|
|
||||||
universalify@^0.1.0:
|
universalify@^0.1.0:
|
||||||
version "0.1.2"
|
version "0.1.2"
|
||||||
resolved "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz"
|
resolved "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
智电改造网关表:electric\_gatway(ICC同步)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 键 | 说明 |
|
||||||
|
| :--------- | :---------- | :----- | :--------------- |
|
||||||
|
| id | BIGSERIAL | PK | 自增主键 |
|
||||||
|
| iccImei | VARCHAR(64) | UNIQUE | 设备唯一标识码,设备的imei号 |
|
||||||
|
| deviceCode | VARCHAR(64) | UNIQUE | 设备编码 |
|
||||||
|
| deviceName | VARCHAR(64) | <br /> | 设备名称 |
|
||||||
|
| <br /> | <br /> | <br /> | <br /> |
|
||||||
|
| <br /> | <br /> | <br /> | <br /> |
|
||||||
|
| <br /> | <br /> | <br /> | <br /> |
|
||||||
|
| <br /> | <br /> | <br /> | <br /> |
|
||||||
|
| <br /> | <br /> | <br /> | <br /> |
|
||||||
|
| <br /> | <br /> | <br /> | <br /> |
|
||||||
|
| <br /> | <br /> | <br /> | <br /> |
|
||||||
|
| <br /> | <br /> | <br /> | <br /> |
|
||||||
|
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
# 鑫潼ICC平台接口文档
|
||||||
|
|
||||||
|
## 接口环境变量
|
||||||
|
|
||||||
|
host:222.243.55.92,端口:1443
|
||||||
|
clientId:xtzdapp
|
||||||
|
clientSecret:1c7e440a-660c-4a5d-a7b8-82b58d9dca4e
|
||||||
|
username:XTZD接口
|
||||||
|
password:Xtzdkj\@20260801
|
||||||
|
version:1.0.0
|
||||||
|
|
||||||
|
## 接口鉴权认证
|
||||||
|
|
||||||
|
大华ICC平台采用OAuth2 鉴权认证。
|
||||||
|
OAuth2是目前最流行的授权机制,是一种安全机制保障的方案。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(Access\_token)和刷新该令牌的凭证(Refresh\_token),用来代替密码,供第三方应用使用。客户端认证模式常用于微服务间交互,该模式Access\_token有效期一般较长,两小时,且没有Refresh\_token,过期可以直接认证获取匿名令牌。
|
||||||
|
授权模式:密码认证模式,密码模式在客户端基础上,从用户方获取帐号密码,再访问授权服务器认证授权。
|
||||||
|

|
||||||
|
|
||||||
|
客户端从用户方获取帐号密码。2、客户端携带用户信息向认证服务器获取令牌。3、认证服务器确认无误后,向客户端提供访问令牌。4、客户端携带令牌访问资源端。
|
||||||
|
协议鉴权
|
||||||
|

|
||||||
|
|
||||||
|
鉴权接口的access\_token有效期2h,权限与鉴权用户的组织、部门权限保持一致。
|
||||||
|
获取公钥:访问地址:https\://{{host}}/evo-apigw/evo-oauth/{{version}}/oauth/public-key。其中{{host}}为接口环境变量的host:端口。{{version}}为接口环境变量的version。
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /evo-apigw/evo-oauth/{{version}}/oauth/public-key HTTP/1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
RSA加密密码。以下是java RSA加密示例:其中使用接口环境变量的username、password。
|
||||||
|
|
||||||
|
```java
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.interfaces.RSAPublicKey;
|
||||||
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public class RSAUtil {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于原生RSA公钥加密
|
||||||
|
* @param password 用户密码
|
||||||
|
* @param publicKey 公钥
|
||||||
|
* @return
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public static String encrypt(String password, String publicKey) throws Exception {
|
||||||
|
//base64编码的公钥
|
||||||
|
byte[] decoded = java.util.Base64.getDecoder().decode(publicKey);
|
||||||
|
RSAPublicKey pubKey = (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
|
||||||
|
//RSA加密
|
||||||
|
Cipher cipher = Cipher.getInstance("RSA");
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, pubKey);
|
||||||
|
String outStr;
|
||||||
|
byte[] inputArray = password.getBytes("UTF-8");
|
||||||
|
int inputLength = inputArray.length;
|
||||||
|
// 最大加密字节数,超出最大字节数需要分组加密
|
||||||
|
int MAX_ENCRYPT_BLOCK = 117;
|
||||||
|
// 标识
|
||||||
|
int offSet = 0;
|
||||||
|
byte[] resultBytes = {};
|
||||||
|
byte[] cache = {};
|
||||||
|
while (inputLength - offSet > 0) {
|
||||||
|
if (inputLength - offSet > MAX_ENCRYPT_BLOCK) {
|
||||||
|
cache = cipher.doFinal(inputArray, offSet, MAX_ENCRYPT_BLOCK);
|
||||||
|
offSet += MAX_ENCRYPT_BLOCK;
|
||||||
|
} else {
|
||||||
|
cache = cipher.doFinal(inputArray, offSet, inputLength - offSet);
|
||||||
|
offSet = inputLength;
|
||||||
|
}
|
||||||
|
resultBytes = Arrays.copyOf(resultBytes, resultBytes.length + cache.length);
|
||||||
|
System.arraycopy(cache, 0, resultBytes, resultBytes.length - cache.length, cache.length);
|
||||||
|
}
|
||||||
|
outStr = java.util.Base64.getEncoder().encodeToString(resultBytes);
|
||||||
|
return outStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
认证:访问凭证client\_id与client\_secret,使用接口环境变量的clientId、clientSecret。access\_token有效期2h。访问地址:https\://{{host}}/evo-apigw/evo-oauth/{{version}}/oauth/extend/token其中使用接口环境变量的username、password、clientId、clientSecret。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /evo-apigw/evo-oauth/1.0.0/oauth/extend/token HTTP/1.1
|
||||||
|
Content-Type: application/json;charset=UTF-8
|
||||||
|
Host: 222.243.55.92:1443
|
||||||
|
|
||||||
|
{
|
||||||
|
"username":"用户账号",
|
||||||
|
"password":"RSA加密后的密码",
|
||||||
|
"client_id":"申请的clientId",
|
||||||
|
"client_secret":"申请的clientSecret",
|
||||||
|
"public_key":"获取的公钥"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回参数示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"access_token": "10000:5cb47cd80-35d9-406d-a8d3-d87dd672d5d3",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"refresh_token": "b83403e1-5ea1-41fd-81fc-e86fe5252c93",
|
||||||
|
"expires_in": 7199,
|
||||||
|
"scope": "*",
|
||||||
|
"userId": "10000",
|
||||||
|
"magicId": "1868a7db-adeb-42de-8064-43b3706ae407"
|
||||||
|
},
|
||||||
|
"code": "0",
|
||||||
|
"errMsg": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
请求头设置鉴权码:调用业务接口需在请求头中设置鉴权码:
|
||||||
|
|
||||||
|
| **头部参数** | **解释** |
|
||||||
|
| ------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Authorization | 格式:token\_type + 空格 + access\_token, token\_type一般为固定值bearer 例如:bearer 1:bb951116-d963-4528-80e1-91c2e94c3e75 |
|
||||||
|
|
||||||
|
## 本次接口说明
|
||||||
|
|
||||||
|
### 一、组织全量数据同步接口
|
||||||
|
|
||||||
|
前置条件:请求头设置Authorization字段
|
||||||
|
接口URL:https\://{{host}}/evo-apigw/evo-brm/{{version}}/organization/page
|
||||||
|
请求Method:GET
|
||||||
|
数据提交格式:application/json
|
||||||
|
请求参数:
|
||||||
|
|
||||||
|
| **参数名** | **必选** | **类型** | **说明** |
|
||||||
|
| ---------- | ------ | ------ | -------------------- |
|
||||||
|
| pageNum | false | int | 当前页, 默认1 |
|
||||||
|
| pageSize | false | int | 每页记录数, 默认10,最大支持1000 |
|
||||||
|
| orgPreCode | false | String | 组织父节点 |
|
||||||
|
| maxRangeId | true | long | 同步边界值 |
|
||||||
|
|
||||||
|
返回参数:
|
||||||
|
|
||||||
|
| **名称** | **类型** | **说明** |
|
||||||
|
| ------- | ------- | -------------------- |
|
||||||
|
| success | boolean | 接口返回是否成功 |
|
||||||
|
| code | string | 错误码:0-为无错误,非0-为具体错误码 |
|
||||||
|
| errMsg | string | 错误消息 |
|
||||||
|
| data | PageVO | 分页数据 |
|
||||||
|
|
||||||
|
PageVO
|
||||||
|
|
||||||
|
| **名称** | **类型** | **说明** |
|
||||||
|
| ----------- | --------------- | ------ |
|
||||||
|
| currentPage | int | 当前页码 |
|
||||||
|
| totalPage | int | 总页数 |
|
||||||
|
| pageSize | int | 每页记录数 |
|
||||||
|
| totalRows | int | 总数 |
|
||||||
|
| pageData | List<OrgInfoVO> | 数据列表 |
|
||||||
|
|
||||||
|
OrgInfoVO
|
||||||
|
|
||||||
|
| **名称** | **类型** | **说明** |
|
||||||
|
| ----------------- | ------- | ------------------------ |
|
||||||
|
| orgCode | string | 组织编码 |
|
||||||
|
| orgName | string | 组织名称 |
|
||||||
|
| orgSn | string | 唯一标识码 |
|
||||||
|
| orgType | string | 组织结构类型,具体参考组织类型 |
|
||||||
|
| province | string | 省 |
|
||||||
|
| city | string | 市 |
|
||||||
|
| county | string | 县 |
|
||||||
|
| domainId | Long | 域ID |
|
||||||
|
| stat | Integer | 状态 |
|
||||||
|
| sort | Integer | 排序码 |
|
||||||
|
| allSort | string | 所有组织排序 |
|
||||||
|
| service | string | 子系统服务编码,默认evo-thirdParty |
|
||||||
|
| forbidBindUser | Integer | 禁止绑定用户 :0-可绑定用户,1-不可绑定用户 |
|
||||||
|
| forbidBindService | String | 禁止绑定用户服务 |
|
||||||
|
|
||||||
|
组织类型
|
||||||
|
|
||||||
|
| **类型key** | **类型value** | **备注** |
|
||||||
|
| ------------- | ----------- | ------ |
|
||||||
|
| org\_base | 基础组织 | <br /> |
|
||||||
|
| org\_country | 国家 | <br /> |
|
||||||
|
| org\_province | 省 | <br /> |
|
||||||
|
| org\_city | 市 | <br /> |
|
||||||
|
| org\_county | 区县 | <br /> |
|
||||||
|
|
||||||
|
接口数据字段与customer(客户信息表)对应关系:
|
||||||
|
|
||||||
|
| **接口字段名称** | **字段说明** | **客户信息表字段名称** | **字段说明** |
|
||||||
|
| ---------- | -------- | -------------- | -------- |
|
||||||
|
| orgCode | 组织编码 | iccCode | ICC客户编码 |
|
||||||
|
| orgName | 组织名称 | customer\_name | 客户名称 |
|
||||||
|
| province | 省 | province | 省 |
|
||||||
|
| city | 市 | city | 市 |
|
||||||
|
| county | 县 | district | 区/县 |
|
||||||
|
|
||||||
|
**二、消防物联设备分页查询接口**
|
||||||
|
|
||||||
|
前置条件:请求头设置Authorization字段
|
||||||
|
接口URL:https\://{{host}}/evo-apigw/evo-fdbu/{{version}}/device/subsystem/page
|
||||||
|
请求Method:POST 数据提交格式:application/json
|
||||||
|
请求参数:
|
||||||
|
|
||||||
|
| **参数名** | **必选** | **类型** | **说明** |
|
||||||
|
| :----------- | :----- | :----- | :--------------- |
|
||||||
|
| pageNum | false | int | 当前页, 默认1 |
|
||||||
|
| pageSize | false | int | 每页记录数, 默认10,最多50 |
|
||||||
|
| orgCodes | false | array | 设备所属组织编码集合,最多10 |
|
||||||
|
| typeCode | false | string | 主机类型 |
|
||||||
|
| keyCondition | false | string | 设备名称/设备编号/imei |
|
||||||
|
|
||||||
|
**请求参数示例**
|
||||||
|
|
||||||
|
```JSON
|
||||||
|
{
|
||||||
|
"pageNum": 1,
|
||||||
|
"pageSize": 10,
|
||||||
|
"orgCodes": ["001001", "002001"],
|
||||||
|
"typeCode": 128,
|
||||||
|
"keyCondition": "测试"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回参数**
|
||||||
|
|
||||||
|
| **名称** | **类型** | **说明** |
|
||||||
|
| :------ | :--------- | :------------------- |
|
||||||
|
| success | boolean | 接口返回是否成功 |
|
||||||
|
| code | string | 错误码:0-为无错误,非0-为具体错误码 |
|
||||||
|
| errMsg | string | 错误消息 |
|
||||||
|
| data | **PageVO** | 分页数据 |
|
||||||
|
|
||||||
|
**PageVO**
|
||||||
|
|
||||||
|
| **名称** | **类型** | **说明** |
|
||||||
|
| :--------- | :--------------------- | :----- |
|
||||||
|
| nextPage | int | 当前页码 |
|
||||||
|
| totalCount | int | 总数 |
|
||||||
|
| pageData | List<**DeviceInfoVO**> | 数据列表 |
|
||||||
|
|
||||||
|
**DeviceInfoVO**
|
||||||
|
|
||||||
|
| **名称** | **类型** | **说明** |
|
||||||
|
| :--------------- | :----- | :--------------- |
|
||||||
|
| id | string | 唯一主键 |
|
||||||
|
| imei | String | 设备唯一标识码,设备的imei号 |
|
||||||
|
| deviceCode | String | 设备编码 |
|
||||||
|
| deviceName | String | 设备名称 |
|
||||||
|
| companyId | String | 公司编号 |
|
||||||
|
| companyName | String | 公司名称 |
|
||||||
|
| manufacturerCode | String | 厂商编码 |
|
||||||
|
| manufacturerName | String | 厂商名称 |
|
||||||
|
| typeCode | String | 主机类型 |
|
||||||
|
| typeName | String | 主机类型名称 |
|
||||||
|
| orgCode | String | 组织编码 |
|
||||||
|
| orgName | String | 组织名称 |
|
||||||
|
| dcpsId | string | 接入设备域 |
|
||||||
|
| softwareVersion | string | 软件版本号 |
|
||||||
|
| systemAddress | int | 系统地址 |
|
||||||
|
| address | string | 安装地址 |
|
||||||
|
| productModel | String | 设备型号 |
|
||||||
|
| isOnline | string | 在离线状态 |
|
||||||
|
| description | string | 描述 |
|
||||||
|
|
||||||
|
接口数据字段与electric\_gateway(智能改造设备表)对应关系:
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
三、**消防探测器分页查询**
|
||||||
|
|
||||||
|
前置条件:请求头设置Authorization字段
|
||||||
|
接口URL:https\://{{host}}/evo-apigw/evo-fdbu/{{version}}/detector/subsystem/page
|
||||||
|
请求Method:POST 数据提交格式:application/json
|
||||||
|
请求参数:
|
||||||
|
|
||||||
|
| **参数名** | **必选** | **类型** | **说明** |
|
||||||
|
| :----------- | :----- | :----- | :--------------- |
|
||||||
|
| pageNum | false | int | 当前页, 默认1 |
|
||||||
|
| pageSize | false | int | 每页记录数, 默认10,最多50 |
|
||||||
|
| orgCodes | false | array | 设备所属组织编码集合,最多10 |
|
||||||
|
| typeCode | false | string | 探测器类型 |
|
||||||
|
| keyCondition | false | string | 设备名称/设备编号/imei |
|
||||||
|
|
||||||
|
**请求参数示例**
|
||||||
|
|
||||||
|
```JSON
|
||||||
|
{
|
||||||
|
"pageNum": 1,
|
||||||
|
"pageSize": 10,
|
||||||
|
"orgCodes": ["001001", "002001"],
|
||||||
|
"typeCode": 128,
|
||||||
|
"keyCondition": "测试"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回参数**
|
||||||
|
|
||||||
|
| **名称** | **类型** | **说明** |
|
||||||
|
| :------ | :--------- | :------------------- |
|
||||||
|
| success | boolean | 接口返回是否成功 |
|
||||||
|
| code | string | 错误码:0-为无错误,非0-为具体错误码 |
|
||||||
|
| errMsg | string | 错误消息 |
|
||||||
|
| data | **PageVO** | 分页数据 |
|
||||||
|
|
||||||
|
**PageVO**
|
||||||
|
|
||||||
|
| **名称** | **类型** | **说明** |
|
||||||
|
| :--------- | :------------------- | :----- |
|
||||||
|
| nextPage | int | 当前页码 |
|
||||||
|
| totalCount | int | 总数 |
|
||||||
|
| pageData | List<**DetectorVO**> | 数据列表 |
|
||||||
|
|
||||||
|
**DetectorVO**
|
||||||
|
|
||||||
|
| **名称** | **类型** | **说明** |
|
||||||
|
| :--------------- | :----- | :----------------- |
|
||||||
|
| id | string | 唯一主键 |
|
||||||
|
| detectorCode | string | 探测器编码 |
|
||||||
|
| detectorName | string | 探测器名称 |
|
||||||
|
| imei | string | 设备唯一标识码,设备的imei号 |
|
||||||
|
| detectorStatus | int | 探测器状态,参考上报状态 |
|
||||||
|
| orgCode | String | 组织编码 |
|
||||||
|
| orgName | String | 组织名称 |
|
||||||
|
| companyId | String | 探测器所属单位编码 |
|
||||||
|
| companyName | String | 探测器所属单位名称 |
|
||||||
|
| parentCode | string | 父设备编码 |
|
||||||
|
| parentName | string | 父设备名称 |
|
||||||
|
| manufacturerCode | String | 厂商编码 |
|
||||||
|
| manufacturerName | String | 厂商名称 |
|
||||||
|
| typeCode | String | 探测器类型 |
|
||||||
|
| typeName | String | 探测器类型名称 |
|
||||||
|
| fireSystemCode | String | 消防系统编码 |
|
||||||
|
| fireSystemName | String | 消防系统名称 |
|
||||||
|
| partUnitLoopCode | string | 探测器回路号 |
|
||||||
|
| channelNo | string | 探测器通道号 |
|
||||||
|
| systemAddress | int | 系统地址 |
|
||||||
|
| channelSn | string | 通道唯一标识码 |
|
||||||
|
| mapType | String | 地图类型 |
|
||||||
|
| produceDate | string | 生产日期,格式:yyyy-MM-dd |
|
||||||
|
|
||||||
|
**上报状态**
|
||||||
|
|
||||||
|
| **状态编码** | **状态名称** | **备注** |
|
||||||
|
| :------- | :------- | :----- |
|
||||||
|
| 01 | 在线 | <br /> |
|
||||||
|
| 02 | 离线 | <br /> |
|
||||||
|
|
||||||
|
接口数据字段与electric\_**line**(智电改造线路表)对应关系:
|
||||||
|
|
||||||
Reference in new issue
Block a user