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,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 { noticeScanJob } from "./notice_scan.task";
|
||||
import { iccLineSyncJob } from "./icc_line_sync.task";
|
||||
import { iccCustomerSyncJob } from "./icc_customer_sync.task";
|
||||
|
||||
export type JobParamType = "number" | "string";
|
||||
|
||||
@@ -32,6 +33,8 @@ export interface JobRuntime {
|
||||
runNoticeRules(): Promise<string>;
|
||||
/** 从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert,返回执行摘要 */
|
||||
syncIccLines(): Promise<string>;
|
||||
/** 从 ICC 平台拉取 org_base 组织并按 iccCode 幂等新增/变更客户,返回执行摘要 */
|
||||
syncIccCustomers(): Promise<string>;
|
||||
}
|
||||
|
||||
export interface JobHandlerContext {
|
||||
@@ -58,7 +61,7 @@ export interface BuiltinJob {
|
||||
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 {
|
||||
return BUILTIN_JOBS.find(job => job.jobKey === jobKey);
|
||||
|
||||
Reference in new issue
Block a user