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:
wk authored and trae-solo-agent committed 2026-10-23 18:20:25 +08:00
1 parent be91d04431
commit 9d3bb13ecf
24 files changed
+1159 -5

No files matched your search

+7 -2
View File
@@ -23,6 +23,11 @@ REDIS_HOST=222.243.55.42
REDIS_PORT=26678
REDIS_PASSWORD=
# ICC 平台配置(结算所述智电改造线路数据由 ICC 同步,占位;接入真实平台时填写)
ICC_BASE_URL=
# ICC 平台配置(智电线路同步沿用 ICC_BASE_URL / ICC_TOKEN;客户信息同步走 OAuth2 密码模式)
ICC_BASE_URL=https://222.243.55.92:1443
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
+1
View File
@@ -38,6 +38,7 @@
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5",
"sequelize-typescript": "^2.1.6",
"undici": "^8.12.2",
"uuid": "^10.0.0"
},
"devDependencies": {
+7
View File
@@ -16,6 +16,13 @@ if (existsSync(envFile)) {
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 {
keys: "smart-grid-iot-secret-key",
@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { CustomerService } from "../service/customer.service";
import { IccCustomerSyncService } from "../service/icc_customer_sync.service";
@Controller("/api/customers")
export class CustomerController {
@@ -10,6 +11,9 @@ export class CustomerController {
@Inject()
customerService!: CustomerService;
@Inject()
iccCustomerSyncService!: IccCustomerSyncService;
@Get("/")
async list(@Query() query: any) {
const result = await this.customerService.list(query, this.ctx);
@@ -28,6 +32,16 @@ export class CustomerController {
return result;
}
@Post("/sync/icc")
async syncIccCustomers() {
const result = await this.iccCustomerSyncService.syncFromIcc(this.ctx);
return {
code: 200,
message: "同步完成",
data: result
};
}
@Get("/:id")
async detail(@Param("id") id: string) {
const result = await this.customerService.detail(Number(id), this.ctx);
+7
View File
@@ -32,6 +32,13 @@ export default class Customer extends Model {
})
customerNo!: string;
@Column({
type: DataType.STRING(64),
field: "icc_code",
comment: "ICC客户编码(ICC平台 orgCode,同步来源)"
})
iccCode!: string;
@Column({
type: DataType.STRING(128),
allowNull: false,
+1 -1
View File
@@ -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 offsetMs = now.getTime() + 8 * 60 * 60 * 1000;
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 { NoticeRuleService } from "./notice_rule.service";
import { IccLineSyncService } from "./icc_line_sync.service";
import { IccCustomerSyncService } from "./icc_customer_sync.service";
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 result = await iccService.syncFromIcc();
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}`;
}
};
}
+177
View File
@@ -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;
}
+87
View File
@@ -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 };
}
}
+17
View File
@@ -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();
}
};
+4 -1
View File
@@ -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);
+5
View File
@@ -4537,6 +4537,11 @@ undici-types@~6.21.0:
resolved "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz"
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:
version "0.1.2"
resolved "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz"