feat: 实现RCU升级后端服务初始版本
- 添加Kafka消费者组件用于消费升级事件数据 - 实现数据处理器进行数据验证和转换 - 添加数据库写入组件支持批量写入G5数据库 - 配置环境变量管理连接参数 - 添加日志记录和错误处理机制 - 实现优雅关闭和流控功能
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/bls-upgrade-backend/node_modules
|
||||||
|
/docs/template
|
||||||
55
bls-upgrade-backend/.env
Normal file
55
bls-upgrade-backend/.env
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
KAFKA_BROKERS=kafka.blv-oa.com:9092
|
||||||
|
KAFKA_CLIENT_ID=bls-upgrade-producer
|
||||||
|
KAFKA_GROUP_ID=bls-upgrade-consumer
|
||||||
|
KAFKA_TOPICS=blwlog4Nodejs-rcu-upgrade-topic
|
||||||
|
KAFKA_AUTO_COMMIT=false
|
||||||
|
KAFKA_AUTO_COMMIT_INTERVAL_MS=5000
|
||||||
|
KAFKA_SASL_ENABLED=true
|
||||||
|
KAFKA_SASL_MECHANISM=plain
|
||||||
|
KAFKA_SASL_USERNAME=blwmomo
|
||||||
|
KAFKA_SASL_PASSWORD=blwmomo
|
||||||
|
KAFKA_SSL_ENABLED=false
|
||||||
|
KAFKA_CONSUMER_INSTANCES=1
|
||||||
|
KAFKA_MAX_IN_FLIGHT=5000
|
||||||
|
KAFKA_BATCH_SIZE=1000
|
||||||
|
KAFKA_BATCH_TIMEOUT_MS=20
|
||||||
|
KAFKA_COMMIT_INTERVAL_MS=200
|
||||||
|
KAFKA_COMMIT_ON_ATTEMPT=true
|
||||||
|
KAFKA_FETCH_MAX_BYTES=10485760
|
||||||
|
KAFKA_FETCH_MAX_WAIT_MS=100
|
||||||
|
KAFKA_FETCH_MIN_BYTES=1
|
||||||
|
|
||||||
|
#POSTGRES_HOST=10.8.8.109
|
||||||
|
#POSTGRES_PORT=5433
|
||||||
|
#POSTGRES_DATABASE=log_platform
|
||||||
|
#POSTGRES_USER=log_admin
|
||||||
|
#POSTGRES_PASSWORD=YourActualStrongPasswordForPostgres!
|
||||||
|
#POSTGRES_MAX_CONNECTIONS=6
|
||||||
|
#POSTGRES_IDLE_TIMEOUT_MS=30000
|
||||||
|
#DB_SCHEMA=onoffline
|
||||||
|
#DB_TABLE=onoffline_record
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# PostgreSQL 配置 G5库专用
|
||||||
|
# =========================
|
||||||
|
POSTGRES_HOST_G5=10.8.8.80
|
||||||
|
POSTGRES_PORT_G5=5434
|
||||||
|
POSTGRES_DATABASE_G5=log_platform
|
||||||
|
POSTGRES_USER_G5=log_admin
|
||||||
|
POSTGRES_PASSWORD_G5=H3IkLUt8K!x
|
||||||
|
POSTGRES_IDLE_TIMEOUT_MS_G5=30000
|
||||||
|
POSTGRES_MAX_CONNECTIONS_G5=2
|
||||||
|
DB_SCHEMA_G5=rcu_upgrade
|
||||||
|
DB_TABLE_G5=rcu_upgrade_events_g5
|
||||||
|
|
||||||
|
|
||||||
|
PORT=3001
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Redis connection
|
||||||
|
REDIS_HOST=10.8.8.109
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=15
|
||||||
|
REDIS_CONNECT_TIMEOUT_MS=5000
|
||||||
|
REDIS_PROJECT_NAME=bls-onoffline
|
||||||
560
bls-upgrade-backend/dist/index.js
vendored
Normal file
560
bls-upgrade-backend/dist/index.js
vendored
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import kafka from "kafka-node";
|
||||||
|
dotenv.config();
|
||||||
|
const config = {
|
||||||
|
port: process.env.PORT || 3001,
|
||||||
|
logLevel: process.env.LOG_LEVEL || "info",
|
||||||
|
kafka: {
|
||||||
|
brokers: process.env.KAFKA_BROKERS || "kafka.blv-oa.com:9092",
|
||||||
|
clientId: process.env.KAFKA_CLIENT_ID || "bls-upgrade-producer",
|
||||||
|
groupId: process.env.KAFKA_GROUP_ID || "bls-upgrade-consumer",
|
||||||
|
testGroupId: process.env.KAFKA_TEST_GROUP_ID || "",
|
||||||
|
topics: process.env.KAFKA_TOPICS || "blwlog4Nodejs-rcu-upgrade-topic",
|
||||||
|
fromOffset: process.env.KAFKA_FROM_OFFSET || "latest",
|
||||||
|
autoCommit: process.env.KAFKA_AUTO_COMMIT === "true",
|
||||||
|
autoCommitIntervalMs: parseInt(process.env.KAFKA_AUTO_COMMIT_INTERVAL_MS || "5000"),
|
||||||
|
saslEnabled: process.env.KAFKA_SASL_ENABLED === "true",
|
||||||
|
saslMechanism: process.env.KAFKA_SASL_MECHANISM || "plain",
|
||||||
|
saslUsername: process.env.KAFKA_SASL_USERNAME || "blwmomo",
|
||||||
|
saslPassword: process.env.KAFKA_SASL_PASSWORD || "blwmomo",
|
||||||
|
sslEnabled: process.env.KAFKA_SSL_ENABLED === "true",
|
||||||
|
consumerInstances: parseInt(process.env.KAFKA_CONSUMER_INSTANCES || "1"),
|
||||||
|
maxInFlight: parseInt(process.env.KAFKA_MAX_IN_FLIGHT || "5000"),
|
||||||
|
batchSize: parseInt(process.env.KAFKA_BATCH_SIZE || "1000"),
|
||||||
|
batchTimeoutMs: parseInt(process.env.KAFKA_BATCH_TIMEOUT_MS || "20"),
|
||||||
|
commitIntervalMs: parseInt(process.env.KAFKA_COMMIT_INTERVAL_MS || "200"),
|
||||||
|
commitOnAttempt: process.env.KAFKA_COMMIT_ON_ATTEMPT === "true",
|
||||||
|
fetchMaxBytes: parseInt(process.env.KAFKA_FETCH_MAX_BYTES || "10485760"),
|
||||||
|
fetchMaxWaitMs: parseInt(process.env.KAFKA_FETCH_MAX_WAIT_MS || "100"),
|
||||||
|
fetchMinBytes: parseInt(process.env.KAFKA_FETCH_MIN_BYTES || "1")
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
g5: {
|
||||||
|
host: process.env.POSTGRES_HOST_G5 || "10.8.8.80",
|
||||||
|
port: parseInt(process.env.POSTGRES_PORT_G5 || "5434"),
|
||||||
|
database: process.env.POSTGRES_DATABASE_G5 || "log_platform",
|
||||||
|
user: process.env.POSTGRES_USER_G5 || "log_admin",
|
||||||
|
password: process.env.POSTGRES_PASSWORD_G5 || "H3IkLUt8K!x",
|
||||||
|
idleTimeoutMs: parseInt(process.env.POSTGRES_IDLE_TIMEOUT_MS_G5 || "30000"),
|
||||||
|
maxConnections: parseInt(process.env.POSTGRES_MAX_CONNECTIONS_G5 || "2"),
|
||||||
|
schema: process.env.DB_SCHEMA_G5 || "rcu_upgrade",
|
||||||
|
table: process.env.DB_TABLE_G5 || "rcu_upgrade_events_g5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
host: process.env.REDIS_HOST || "10.8.8.109",
|
||||||
|
port: parseInt(process.env.REDIS_PORT || "6379"),
|
||||||
|
password: process.env.REDIS_PASSWORD || "",
|
||||||
|
db: parseInt(process.env.REDIS_DB || "15"),
|
||||||
|
connectTimeoutMs: parseInt(process.env.REDIS_CONNECT_TIMEOUT_MS || "5000"),
|
||||||
|
projectName: process.env.REDIS_PROJECT_NAME || "bls-onoffline"
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
dbWriteIntervalMs: 1e3,
|
||||||
|
// 限制数据库写入频率为每秒最多1次
|
||||||
|
batchSize: 1e3
|
||||||
|
// 批处理大小
|
||||||
|
}
|
||||||
|
};
|
||||||
|
class Logger {
|
||||||
|
constructor() {
|
||||||
|
this.logLevel = config.logLevel;
|
||||||
|
}
|
||||||
|
log(level, message, data = {}) {
|
||||||
|
const levels = ["debug", "info", "warn", "error"];
|
||||||
|
const levelIndex = levels.indexOf(level);
|
||||||
|
const configLevelIndex = levels.indexOf(this.logLevel);
|
||||||
|
if (levelIndex >= configLevelIndex) {
|
||||||
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
||||||
|
const logMessage = {
|
||||||
|
timestamp,
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
data
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(logMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug(message, data = {}) {
|
||||||
|
this.log("debug", message, data);
|
||||||
|
}
|
||||||
|
info(message, data = {}) {
|
||||||
|
this.log("info", message, data);
|
||||||
|
}
|
||||||
|
warn(message, data = {}) {
|
||||||
|
this.log("warn", message, data);
|
||||||
|
}
|
||||||
|
error(message, data = {}) {
|
||||||
|
this.log("error", message, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const logger = new Logger();
|
||||||
|
class DatabaseManager {
|
||||||
|
constructor() {
|
||||||
|
this.pools = {};
|
||||||
|
}
|
||||||
|
init() {
|
||||||
|
this.pools.g5 = new Pool({
|
||||||
|
host: config.database.g5.host,
|
||||||
|
port: config.database.g5.port,
|
||||||
|
database: config.database.g5.database,
|
||||||
|
user: config.database.g5.user,
|
||||||
|
password: config.database.g5.password,
|
||||||
|
max: config.database.g5.maxConnections,
|
||||||
|
idleTimeoutMillis: config.database.g5.idleTimeoutMs
|
||||||
|
});
|
||||||
|
this.pools.g5.connect((err, client, release) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error("Error connecting to G5 database:", { error: err.message });
|
||||||
|
} else {
|
||||||
|
logger.info("Successfully connected to G5 database");
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.pools.g5.on("error", (err) => {
|
||||||
|
logger.error("Unexpected error on G5 database connection pool:", { error: err.message });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getPool(dbName) {
|
||||||
|
return this.pools[dbName];
|
||||||
|
}
|
||||||
|
async query(dbName, text, params) {
|
||||||
|
const pool = this.getPool(dbName);
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error(`Database pool ${dbName} not initialized`);
|
||||||
|
}
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
return await client.query(text, params);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async close() {
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(this.pools).map((pool) => pool.end())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const databaseManager = new DatabaseManager();
|
||||||
|
class OffsetTracker {
|
||||||
|
constructor() {
|
||||||
|
this.partitions = /* @__PURE__ */ new Map();
|
||||||
|
}
|
||||||
|
add(topic, partition, offset) {
|
||||||
|
const key = `${topic}-${partition}`;
|
||||||
|
if (!this.partitions.has(key)) {
|
||||||
|
this.partitions.set(key, { nextCommitOffset: null, done: /* @__PURE__ */ new Set() });
|
||||||
|
}
|
||||||
|
const state = this.partitions.get(key);
|
||||||
|
const numericOffset = Number(offset);
|
||||||
|
if (!Number.isFinite(numericOffset)) return;
|
||||||
|
if (state.nextCommitOffset === null) {
|
||||||
|
state.nextCommitOffset = numericOffset;
|
||||||
|
} else if (numericOffset < state.nextCommitOffset) {
|
||||||
|
state.nextCommitOffset = numericOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markDone(topic, partition, offset) {
|
||||||
|
const key = `${topic}-${partition}`;
|
||||||
|
const state = this.partitions.get(key);
|
||||||
|
if (!state) return null;
|
||||||
|
const numericOffset = Number(offset);
|
||||||
|
if (!Number.isFinite(numericOffset)) return null;
|
||||||
|
state.done.add(numericOffset);
|
||||||
|
if (state.nextCommitOffset === null) {
|
||||||
|
state.nextCommitOffset = numericOffset;
|
||||||
|
}
|
||||||
|
let advanced = false;
|
||||||
|
while (state.nextCommitOffset !== null && state.done.has(state.nextCommitOffset)) {
|
||||||
|
state.done.delete(state.nextCommitOffset);
|
||||||
|
state.nextCommitOffset += 1;
|
||||||
|
advanced = true;
|
||||||
|
}
|
||||||
|
if (!advanced) return null;
|
||||||
|
return state.nextCommitOffset;
|
||||||
|
}
|
||||||
|
clear() {
|
||||||
|
this.partitions.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { ConsumerGroup } = kafka;
|
||||||
|
class KafkaConsumer {
|
||||||
|
constructor() {
|
||||||
|
this.consumer = null;
|
||||||
|
this.tracker = new OffsetTracker();
|
||||||
|
this.pendingCommits = /* @__PURE__ */ new Map();
|
||||||
|
this.commitTimer = null;
|
||||||
|
this.inFlight = 0;
|
||||||
|
this.maxInFlight = Number.isFinite(config.kafka.maxInFlight) ? config.kafka.maxInFlight : 5e3;
|
||||||
|
this.commitIntervalMs = Number.isFinite(config.kafka.commitIntervalMs) ? config.kafka.commitIntervalMs : 200;
|
||||||
|
}
|
||||||
|
init() {
|
||||||
|
const kafkaConfig = {
|
||||||
|
kafkaHost: config.kafka.brokers,
|
||||||
|
clientId: config.kafka.clientId,
|
||||||
|
groupId: config.kafka.groupId,
|
||||||
|
fromOffset: config.kafka.fromOffset,
|
||||||
|
protocol: ["roundrobin"],
|
||||||
|
outOfRangeOffset: "latest",
|
||||||
|
autoCommit: config.kafka.autoCommit,
|
||||||
|
autoCommitIntervalMs: config.kafka.autoCommitIntervalMs,
|
||||||
|
fetchMaxBytes: config.kafka.fetchMaxBytes,
|
||||||
|
fetchMaxWaitMs: config.kafka.fetchMaxWaitMs,
|
||||||
|
fetchMinBytes: config.kafka.fetchMinBytes,
|
||||||
|
sasl: config.kafka.saslEnabled ? {
|
||||||
|
mechanism: config.kafka.saslMechanism,
|
||||||
|
username: config.kafka.saslUsername,
|
||||||
|
password: config.kafka.saslPassword
|
||||||
|
} : void 0,
|
||||||
|
ssl: config.kafka.sslEnabled,
|
||||||
|
connectTimeout: 1e4,
|
||||||
|
requestTimeout: 1e4
|
||||||
|
};
|
||||||
|
logger.info("Initializing Kafka consumer with config:", {
|
||||||
|
kafkaHost: config.kafka.brokers,
|
||||||
|
clientId: config.kafka.clientId,
|
||||||
|
groupId: config.kafka.groupId,
|
||||||
|
topics: config.kafka.topics,
|
||||||
|
fromOffset: config.kafka.fromOffset,
|
||||||
|
saslEnabled: config.kafka.saslEnabled
|
||||||
|
});
|
||||||
|
const topics = config.kafka.topics.split(",").map((topic) => topic.trim()).filter(Boolean);
|
||||||
|
this.consumer = new ConsumerGroup(kafkaConfig, topics);
|
||||||
|
this.consumer.on("connect", () => {
|
||||||
|
logger.info("Kafka consumer connected", {
|
||||||
|
groupId: config.kafka.groupId,
|
||||||
|
topics
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.consumer.on("rebalancing", () => {
|
||||||
|
logger.info("Kafka consumer rebalancing");
|
||||||
|
this.tracker.clear();
|
||||||
|
this.pendingCommits.clear();
|
||||||
|
if (this.commitTimer) {
|
||||||
|
clearTimeout(this.commitTimer);
|
||||||
|
this.commitTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.consumer.on("rebalanced", () => {
|
||||||
|
logger.info("Kafka consumer rebalanced");
|
||||||
|
});
|
||||||
|
this.consumer.on("message", (message) => {
|
||||||
|
logger.debug("Received Kafka message:", { messageId: message.offset });
|
||||||
|
this.inFlight += 1;
|
||||||
|
this.tracker.add(message.topic, message.partition, message.offset);
|
||||||
|
if (this.inFlight >= this.maxInFlight && this.consumer.pause) {
|
||||||
|
this.consumer.pause();
|
||||||
|
}
|
||||||
|
Promise.resolve(this.onMessage(message)).then(() => {
|
||||||
|
if (!config.kafka.autoCommit) {
|
||||||
|
const commitOffset = this.tracker.markDone(message.topic, message.partition, message.offset);
|
||||||
|
if (commitOffset !== null) {
|
||||||
|
const key = `${message.topic}-${message.partition}`;
|
||||||
|
this.pendingCommits.set(key, {
|
||||||
|
topic: message.topic,
|
||||||
|
partition: message.partition,
|
||||||
|
offset: commitOffset,
|
||||||
|
metadata: "m"
|
||||||
|
});
|
||||||
|
this.scheduleCommitFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.error("Kafka message handling failed, skip commit", {
|
||||||
|
error: err.message,
|
||||||
|
topic: message.topic,
|
||||||
|
partition: message.partition,
|
||||||
|
offset: message.offset
|
||||||
|
});
|
||||||
|
}).finally(() => {
|
||||||
|
this.inFlight -= 1;
|
||||||
|
if (this.inFlight < this.maxInFlight && this.consumer.resume) {
|
||||||
|
this.consumer.resume();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.consumer.on("error", (err) => {
|
||||||
|
logger.error("Kafka consumer error:", { error: err.message, stack: err.stack });
|
||||||
|
});
|
||||||
|
this.consumer.on("offsetOutOfRange", (topic) => {
|
||||||
|
logger.warn("Kafka offset out of range:", { topic: topic.topic, partition: topic.partition });
|
||||||
|
});
|
||||||
|
logger.info("Kafka consumer initialized");
|
||||||
|
this.consumer.on("close", () => {
|
||||||
|
logger.info("Kafka consumer closed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onMessage(message) {
|
||||||
|
}
|
||||||
|
scheduleCommitFlush() {
|
||||||
|
if (this.commitTimer) return;
|
||||||
|
this.commitTimer = setTimeout(() => {
|
||||||
|
this.commitTimer = null;
|
||||||
|
this.flushCommits();
|
||||||
|
}, this.commitIntervalMs);
|
||||||
|
}
|
||||||
|
flushCommits() {
|
||||||
|
if (!this.consumer || this.pendingCommits.size === 0) return;
|
||||||
|
const batch = this.pendingCommits;
|
||||||
|
this.pendingCommits = /* @__PURE__ */ new Map();
|
||||||
|
this.consumer.sendOffsetCommitRequest(Array.from(batch.values()), (err) => {
|
||||||
|
if (err) {
|
||||||
|
for (const [k, v] of batch.entries()) {
|
||||||
|
this.pendingCommits.set(k, v);
|
||||||
|
}
|
||||||
|
logger.error("Failed to commit Kafka offsets", {
|
||||||
|
error: err.message,
|
||||||
|
groupId: config.kafka.groupId,
|
||||||
|
count: batch.size
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info("Kafka offsets committed", {
|
||||||
|
groupId: config.kafka.groupId,
|
||||||
|
count: batch.size,
|
||||||
|
commits: Array.from(batch.values())
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (this.commitTimer) {
|
||||||
|
clearTimeout(this.commitTimer);
|
||||||
|
this.commitTimer = null;
|
||||||
|
}
|
||||||
|
this.flushCommits();
|
||||||
|
if (!this.consumer) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.consumer.close(true, () => {
|
||||||
|
logger.info("Kafka consumer closed");
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class DataProcessor {
|
||||||
|
constructor() {
|
||||||
|
this.batch = [];
|
||||||
|
this.lastWriteTime = 0;
|
||||||
|
this.flushTimer = null;
|
||||||
|
this.dbRetryDelayMs = 1e3;
|
||||||
|
this.dbRetryMaxAttempts = 3;
|
||||||
|
}
|
||||||
|
async processMessage(message) {
|
||||||
|
try {
|
||||||
|
const rawValue = Buffer.isBuffer(message.value) ? message.value.toString("utf8") : String(message.value);
|
||||||
|
const payload = JSON.parse(rawValue);
|
||||||
|
const processedData = this.validateAndTransform(payload);
|
||||||
|
const writeAck = new Promise((resolve, reject) => {
|
||||||
|
this.batch.push({
|
||||||
|
data: processedData,
|
||||||
|
meta: {
|
||||||
|
topic: message.topic,
|
||||||
|
partition: message.partition,
|
||||||
|
offset: message.offset
|
||||||
|
},
|
||||||
|
resolve,
|
||||||
|
reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger.debug("Message accepted into batch", {
|
||||||
|
topic: message.topic,
|
||||||
|
partition: message.partition,
|
||||||
|
offset: message.offset,
|
||||||
|
currentBatchSize: this.batch.length
|
||||||
|
});
|
||||||
|
this.scheduleFlush();
|
||||||
|
await this.checkAndWriteBatch();
|
||||||
|
await writeAck;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error processing message:", { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateAndTransform(data) {
|
||||||
|
const cleanString = (str) => {
|
||||||
|
if (str === null || str === void 0) return null;
|
||||||
|
return String(str).replace(/\0/g, "");
|
||||||
|
};
|
||||||
|
const processed = {
|
||||||
|
ts_ms: typeof data.ts_ms === "number" ? data.ts_ms : parseInt(data.ts_ms) || 0,
|
||||||
|
hotel_id: this.validateHotelId(data.hotel_id),
|
||||||
|
room_id: cleanString(data.room_id) || "",
|
||||||
|
device_id: cleanString(data.device_id) || "",
|
||||||
|
is_send: parseInt(data.is_send) || 0,
|
||||||
|
udp_raw: data.udp_raw ? cleanString(Buffer.from(data.udp_raw).toString()) : null,
|
||||||
|
extra: data.extra ? JSON.stringify(data.extra) : null,
|
||||||
|
ip: cleanString(data.remote_endpoint) || "",
|
||||||
|
md5: cleanString(data.md5) || "",
|
||||||
|
partition: parseInt(data.partition) || null,
|
||||||
|
file_type: parseInt(data.file_type) || null,
|
||||||
|
file_path: cleanString(data.file_path) || "",
|
||||||
|
upgrade_state: parseInt(data.upgrade_state) || null,
|
||||||
|
app_version: cleanString(data.app_version) || ""
|
||||||
|
};
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
validateHotelId(hotelId) {
|
||||||
|
const id = parseInt(hotelId);
|
||||||
|
if (isNaN(id) || id < -32768 || id > 32767) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
async checkAndWriteBatch() {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastWrite = now - this.lastWriteTime;
|
||||||
|
if (this.batch.length >= config.performance.batchSize || timeSinceLastWrite >= config.performance.dbWriteIntervalMs) {
|
||||||
|
await this.writeBatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleFlush() {
|
||||||
|
if (this.flushTimer) return;
|
||||||
|
this.flushTimer = setTimeout(() => {
|
||||||
|
this.flushTimer = null;
|
||||||
|
this.writeBatch().catch((error) => {
|
||||||
|
logger.error("Error in scheduled batch flush:", { error: error.message });
|
||||||
|
});
|
||||||
|
}, config.performance.dbWriteIntervalMs);
|
||||||
|
}
|
||||||
|
isRetryableDbError(err) {
|
||||||
|
const code = err == null ? void 0 : err.code;
|
||||||
|
if (typeof code === "string") {
|
||||||
|
const retryableCodes = /* @__PURE__ */ new Set([
|
||||||
|
"ECONNREFUSED",
|
||||||
|
"ECONNRESET",
|
||||||
|
"EPIPE",
|
||||||
|
"ETIMEDOUT",
|
||||||
|
"ENOTFOUND",
|
||||||
|
"EHOSTUNREACH",
|
||||||
|
"ENETUNREACH",
|
||||||
|
"57P03",
|
||||||
|
"08006",
|
||||||
|
"08001",
|
||||||
|
"08000",
|
||||||
|
"08003"
|
||||||
|
]);
|
||||||
|
if (retryableCodes.has(code)) return true;
|
||||||
|
}
|
||||||
|
const message = typeof (err == null ? void 0 : err.message) === "string" ? err.message.toLowerCase() : "";
|
||||||
|
return message.includes("connection timeout") || message.includes("connection terminated") || message.includes("connection refused") || message.includes("econnrefused") || message.includes("econnreset") || message.includes("etimedout") || message.includes("could not connect") || message.includes("the database system is starting up");
|
||||||
|
}
|
||||||
|
async executeQueryWithRetry(query, params) {
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await databaseManager.query("g5", query, params);
|
||||||
|
} catch (error) {
|
||||||
|
attempt += 1;
|
||||||
|
if (!this.isRetryableDbError(error) || attempt > this.dbRetryMaxAttempts) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.warn("Retrying G5 batch write after transient DB error", {
|
||||||
|
attempt,
|
||||||
|
maxAttempts: this.dbRetryMaxAttempts,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, this.dbRetryDelayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async writeBatch() {
|
||||||
|
var _a, _b, _c, _d, _e, _f;
|
||||||
|
if (this.batch.length === 0) return;
|
||||||
|
if (this.flushTimer) {
|
||||||
|
clearTimeout(this.flushTimer);
|
||||||
|
this.flushTimer = null;
|
||||||
|
}
|
||||||
|
const batch = [...this.batch];
|
||||||
|
this.batch = [];
|
||||||
|
this.lastWriteTime = Date.now();
|
||||||
|
try {
|
||||||
|
logger.info("Flushing batch to G5 database", {
|
||||||
|
batchSize: batch.length,
|
||||||
|
first: (_a = batch[0]) == null ? void 0 : _a.meta,
|
||||||
|
last: (_b = batch[batch.length - 1]) == null ? void 0 : _b.meta
|
||||||
|
});
|
||||||
|
const values = batch.map((item) => [
|
||||||
|
item.data.ts_ms,
|
||||||
|
item.data.hotel_id,
|
||||||
|
item.data.room_id,
|
||||||
|
item.data.device_id,
|
||||||
|
item.data.is_send,
|
||||||
|
item.data.udp_raw,
|
||||||
|
item.data.extra,
|
||||||
|
item.data.ip,
|
||||||
|
item.data.md5,
|
||||||
|
item.data.partition,
|
||||||
|
item.data.file_type,
|
||||||
|
item.data.file_path,
|
||||||
|
item.data.upgrade_state,
|
||||||
|
item.data.app_version
|
||||||
|
]);
|
||||||
|
const query = `
|
||||||
|
INSERT INTO ${config.database.g5.schema}.${config.database.g5.table}
|
||||||
|
(ts_ms, hotel_id, room_id, device_id, is_send, udp_raw, extra, ip, md5, partition, file_type, file_path, upgrade_state, app_version)
|
||||||
|
VALUES ${values.map((_, i) => `($${i * 14 + 1}, $${i * 14 + 2}, $${i * 14 + 3}, $${i * 14 + 4}, $${i * 14 + 5}, $${i * 14 + 6}, $${i * 14 + 7}, $${i * 14 + 8}, $${i * 14 + 9}, $${i * 14 + 10}, $${i * 14 + 11}, $${i * 14 + 12}, $${i * 14 + 13}, $${i * 14 + 14})`).join(", ")}
|
||||||
|
`;
|
||||||
|
const params = values.flat();
|
||||||
|
await this.executeQueryWithRetry(query, params);
|
||||||
|
logger.info("Batch write success", {
|
||||||
|
batchSize: batch.length,
|
||||||
|
first: (_c = batch[0]) == null ? void 0 : _c.meta,
|
||||||
|
last: (_d = batch[batch.length - 1]) == null ? void 0 : _d.meta
|
||||||
|
});
|
||||||
|
batch.forEach((item) => item.resolve());
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error writing batch to database:", {
|
||||||
|
error: error.message,
|
||||||
|
batchSize: batch.length,
|
||||||
|
first: (_e = batch[0]) == null ? void 0 : _e.meta,
|
||||||
|
last: (_f = batch[batch.length - 1]) == null ? void 0 : _f.meta
|
||||||
|
});
|
||||||
|
batch.forEach((item) => item.reject(error));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async flush() {
|
||||||
|
if (this.batch.length > 0) {
|
||||||
|
await this.writeBatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dataProcessor = new DataProcessor();
|
||||||
|
class App {
|
||||||
|
constructor() {
|
||||||
|
this.consumer = null;
|
||||||
|
this.isShuttingDown = false;
|
||||||
|
}
|
||||||
|
async init() {
|
||||||
|
databaseManager.init();
|
||||||
|
this.consumer = new KafkaConsumer();
|
||||||
|
this.consumer.onMessage = (message) => dataProcessor.processMessage(message);
|
||||||
|
this.consumer.init();
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
await this.shutdown();
|
||||||
|
});
|
||||||
|
process.on("SIGTERM", async () => {
|
||||||
|
await this.shutdown();
|
||||||
|
});
|
||||||
|
logger.info(`BLS Upgrade Backend service started on port ${config.port}`);
|
||||||
|
}
|
||||||
|
async shutdown() {
|
||||||
|
if (this.isShuttingDown) return;
|
||||||
|
this.isShuttingDown = true;
|
||||||
|
logger.info("Shutting down BLS Upgrade Backend service...");
|
||||||
|
await dataProcessor.flush();
|
||||||
|
if (this.consumer) {
|
||||||
|
await this.consumer.close();
|
||||||
|
}
|
||||||
|
await databaseManager.close();
|
||||||
|
logger.info("Service shutdown completed");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const app = new App();
|
||||||
|
app.init();
|
||||||
22
bls-upgrade-backend/ecosystem.config.cjs
Normal file
22
bls-upgrade-backend/ecosystem.config.cjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'bls-upgrade-backend',
|
||||||
|
script: 'dist/index.js',
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: 'fork',
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '1G',
|
||||||
|
env_file: '.env',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3001
|
||||||
|
},
|
||||||
|
error_file: './logs/error.log',
|
||||||
|
out_file: './logs/out.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
merge_logs: true,
|
||||||
|
kill_timeout: 5000,
|
||||||
|
time: true
|
||||||
|
}]
|
||||||
|
};
|
||||||
42
bls-upgrade-backend/openspec/AGENTS.md
Normal file
42
bls-upgrade-backend/openspec/AGENTS.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Agents
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document lists the agents involved in the RCU Upgrade Backend project.
|
||||||
|
|
||||||
|
## Agents
|
||||||
|
|
||||||
|
### 1. System Administrator
|
||||||
|
- **Responsibilities**: Server setup, network configuration, security
|
||||||
|
- **Contact**: admin@example.com
|
||||||
|
|
||||||
|
### 2. Database Administrator
|
||||||
|
- **Responsibilities**: Database setup, schema management, performance tuning
|
||||||
|
- **Contact**: dba@example.com
|
||||||
|
|
||||||
|
### 3. Kafka Administrator
|
||||||
|
- **Responsibilities**: Kafka cluster management, topic configuration
|
||||||
|
- **Contact**: kafka-admin@example.com
|
||||||
|
|
||||||
|
### 4. Developer
|
||||||
|
- **Responsibilities**: Code implementation, testing, deployment
|
||||||
|
- **Contact**: developer@example.com
|
||||||
|
|
||||||
|
### 5. DevOps Engineer
|
||||||
|
- **Responsibilities**: CI/CD pipeline, monitoring, deployment automation
|
||||||
|
- **Contact**: devops@example.com
|
||||||
|
|
||||||
|
## Agent Responsibilities Matrix
|
||||||
|
|
||||||
|
| Task | System Admin | DBA | Kafka Admin | Developer | DevOps |
|
||||||
|
|------|-------------|-----|-------------|-----------|--------|
|
||||||
|
| Server setup | ✅ | | | | |
|
||||||
|
| Network configuration | ✅ | | | | |
|
||||||
|
| Database setup | | ✅ | | | |
|
||||||
|
| Schema management | | ✅ | | | |
|
||||||
|
| Kafka cluster setup | | | ✅ | | |
|
||||||
|
| Topic configuration | | | ✅ | | |
|
||||||
|
| Code implementation | | | | ✅ | |
|
||||||
|
| Testing | | | | ✅ | |
|
||||||
|
| CI/CD pipeline | | | | | ✅ |
|
||||||
|
| Monitoring | | | | | ✅ |
|
||||||
|
| Deployment automation | | | | | ✅ |
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Initial Implementation Proposal
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This proposal outlines the initial implementation of the RCU Upgrade Backend service, which will consume data from Kafka, process it, and write it to the G5 database.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
The service is needed to handle the processing and storage of RCU upgrade events data coming from Kafka, ensuring data integrity and performance.
|
||||||
|
|
||||||
|
## Proposed Changes
|
||||||
|
1. **Project Structure**: Create a complete Node.js project structure with the following components:
|
||||||
|
- Kafka consumer
|
||||||
|
- Data processor
|
||||||
|
- Database writer
|
||||||
|
- Flow control mechanism
|
||||||
|
|
||||||
|
2. **Configuration**: Set up environment variables for:
|
||||||
|
- Kafka connection
|
||||||
|
- Database connection
|
||||||
|
- Performance settings
|
||||||
|
|
||||||
|
3. **Data Processing**: Implement:
|
||||||
|
- Data validation
|
||||||
|
- hotel_id value range check
|
||||||
|
- Batch processing
|
||||||
|
- Flow control
|
||||||
|
|
||||||
|
4. **Error Handling**: Implement comprehensive error handling and logging
|
||||||
|
|
||||||
|
5. **Testing**: Prepare for unit and integration testing
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
- Efficient processing of high-volume data
|
||||||
|
- Data integrity through validation
|
||||||
|
- Controlled database write frequency
|
||||||
|
- Comprehensive logging and error handling
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- Potential performance issues with large batch sizes
|
||||||
|
- Kafka connection reliability
|
||||||
|
- Database connection limits
|
||||||
|
|
||||||
|
## Mitigation Strategies
|
||||||
|
- Configurable batch size and write frequency
|
||||||
|
- Robust error handling and retry mechanisms
|
||||||
|
- Monitoring and alerting
|
||||||
|
|
||||||
|
## Timeline
|
||||||
|
- Initial implementation: 1 day
|
||||||
|
- Testing: 1 day
|
||||||
|
- Deployment: 1 day
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Initial Implementation Tasks
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document outlines the specific tasks required for the initial implementation of the RCU Upgrade Backend service.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### 1. Project Setup
|
||||||
|
- [x] Create project directory structure
|
||||||
|
- [x] Set up package.json with dependencies
|
||||||
|
- [x] Configure environment variables
|
||||||
|
|
||||||
|
### 2. Core Components
|
||||||
|
- [x] Implement Kafka consumer
|
||||||
|
- [x] Implement data processor
|
||||||
|
- [x] Implement database writer
|
||||||
|
- [x] Implement flow control mechanism
|
||||||
|
|
||||||
|
### 3. Data Processing
|
||||||
|
- [x] Implement data validation
|
||||||
|
- [x] Implement hotel_id value range check
|
||||||
|
- [x] Implement batch processing
|
||||||
|
- [x] Implement flow control
|
||||||
|
|
||||||
|
### 4. Error Handling and Logging
|
||||||
|
- [x] Implement error handling
|
||||||
|
- [x] Implement logging
|
||||||
|
|
||||||
|
### 5. Testing
|
||||||
|
- [ ] Write unit tests
|
||||||
|
- [ ] Write integration tests
|
||||||
|
|
||||||
|
### 6. Deployment
|
||||||
|
- [ ] Set up build process
|
||||||
|
- [ ] Create deployment script
|
||||||
|
|
||||||
|
## Task Details
|
||||||
|
|
||||||
|
### Task 1: Project Setup
|
||||||
|
- Create directory structure including src, tests, scripts, and openspec
|
||||||
|
- Set up package.json with required dependencies (kafka-node, pg, dotenv, etc.)
|
||||||
|
- Configure .env file with connection details
|
||||||
|
|
||||||
|
### Task 2: Core Components
|
||||||
|
- Kafka consumer: Set up connection to Kafka broker and consume messages from blwlog4Nodejs-rcu-upgrade-topic
|
||||||
|
- Data processor: Validate and transform data according to database schema
|
||||||
|
- Database writer: Write processed data to G5 database
|
||||||
|
- Flow control: Limit database write frequency to max 1 time per second
|
||||||
|
|
||||||
|
### Task 3: Data Processing
|
||||||
|
- Data validation: Ensure all fields are present and valid
|
||||||
|
- hotel_id value range check: Ensure hotel_id is within int2 range (-32768 to 32767), otherwise set to 0
|
||||||
|
- Batch processing: Process data in batches of 1000 records
|
||||||
|
- Flow control: Ensure database writes occur at most once per second
|
||||||
|
|
||||||
|
### Task 4: Error Handling and Logging
|
||||||
|
- Error handling: Handle and log errors gracefully
|
||||||
|
- Logging: Implement structured logging for all operations
|
||||||
|
|
||||||
|
### Task 5: Testing
|
||||||
|
- Unit tests: Test individual components
|
||||||
|
- Integration tests: Test the entire flow
|
||||||
|
|
||||||
|
### Task 6: Deployment
|
||||||
|
- Build process: Set up Vite build
|
||||||
|
- Deployment script: Create script for deployment
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- All core components are implemented
|
||||||
|
- Data is correctly processed and written to database
|
||||||
|
- Flow control is working as expected
|
||||||
|
- Error handling and logging are in place
|
||||||
|
- Service can be started and run without errors
|
||||||
42
bls-upgrade-backend/openspec/specs/rcu_upgrade/spec.md
Normal file
42
bls-upgrade-backend/openspec/specs/rcu_upgrade/spec.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# RCU Upgrade Backend Spec
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This service is responsible for consuming data from Kafka, processing it, and writing it to the G5 database.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- **Kafka Consumer**: Consumes data from the blwlog4Nodejs-rcu-upgrade-topic topic
|
||||||
|
- **Data Processor**: Validates and transforms data
|
||||||
|
- **Database Writer**: Writes processed data to the G5 database
|
||||||
|
- **Flow Control**: Limits database write frequency to max 1 time per second
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
1. Kafka consumer receives messages
|
||||||
|
2. Messages are parsed and validated
|
||||||
|
3. Data is transformed to match database schema
|
||||||
|
4. Data is batched and written to database
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
All configuration is managed through environment variables in .env file:
|
||||||
|
- Kafka connection settings
|
||||||
|
- Database connection settings
|
||||||
|
- Performance settings
|
||||||
|
|
||||||
|
## Data Validation
|
||||||
|
- hotel_id: Must be within int2 range (-32768 to 32767), otherwise set to 0
|
||||||
|
- All other fields are validated and default values are provided if missing
|
||||||
|
|
||||||
|
## Performance Requirements
|
||||||
|
- Batch processing: 1000 records per batch
|
||||||
|
- Database write frequency: max 1 time per second
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
- All errors are logged
|
||||||
|
- Failed batches can be retried
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
- Logs are generated for all operations
|
||||||
|
- Performance metrics can be collected
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
- Service is deployed as a Node.js application
|
||||||
|
- Can be run with npm start or as a system service
|
||||||
36
bls-upgrade-backend/openspec/specs/rcu_upgrade/status.md
Normal file
36
bls-upgrade-backend/openspec/specs/rcu_upgrade/status.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# RCU Upgrade Backend Status
|
||||||
|
|
||||||
|
## Status: In Progress
|
||||||
|
|
||||||
|
## Implementation Progress
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
- [x] Kafka Consumer: Implemented
|
||||||
|
- [x] Data Processor: Implemented
|
||||||
|
- [x] Database Writer: Implemented
|
||||||
|
- [x] Flow Control: Implemented
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- [x] Data validation
|
||||||
|
- [x] Batch processing
|
||||||
|
- [x] Error handling
|
||||||
|
- [x] Logging
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- [x] Environment variables
|
||||||
|
- [x] Database connection
|
||||||
|
- [x] Kafka connection
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] Unit tests
|
||||||
|
- [ ] Integration tests
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- [ ] Build process
|
||||||
|
- [ ] Deployment script
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
1. Complete unit tests
|
||||||
|
2. Complete integration tests
|
||||||
|
3. Finalize deployment process
|
||||||
|
4. Perform performance testing
|
||||||
4159
bls-upgrade-backend/package-lock.json
generated
Normal file
4159
bls-upgrade-backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
bls-upgrade-backend/package.json
Normal file
28
bls-upgrade-backend/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "bls-upgrade-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node src/index.js",
|
||||||
|
"dev:test-consumer": "node src/test-consumer.js",
|
||||||
|
"build": "vite build --ssr src/index.js --outDir dist",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "node scripts/lint.js",
|
||||||
|
"spec:lint": "openspec validate --specs --strict --no-interactive",
|
||||||
|
"spec:validate": "openspec validate --specs --no-interactive",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"kafka-node": "^5.0.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
|
"pg": "^8.11.5",
|
||||||
|
"redis": "^4.6.13",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
59
bls-upgrade-backend/src/config/config.js
Normal file
59
bls-upgrade-backend/src/config/config.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: process.env.PORT || 3001,
|
||||||
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
|
kafka: {
|
||||||
|
brokers: process.env.KAFKA_BROKERS || 'kafka.blv-oa.com:9092',
|
||||||
|
clientId: process.env.KAFKA_CLIENT_ID || 'bls-upgrade-producer',
|
||||||
|
groupId: process.env.KAFKA_GROUP_ID || 'bls-upgrade-consumer',
|
||||||
|
testGroupId: process.env.KAFKA_TEST_GROUP_ID || '',
|
||||||
|
topics: process.env.KAFKA_TOPICS || 'blwlog4Nodejs-rcu-upgrade-topic',
|
||||||
|
fromOffset: process.env.KAFKA_FROM_OFFSET || 'latest',
|
||||||
|
autoCommit: process.env.KAFKA_AUTO_COMMIT === 'true',
|
||||||
|
autoCommitIntervalMs: parseInt(process.env.KAFKA_AUTO_COMMIT_INTERVAL_MS || '5000'),
|
||||||
|
saslEnabled: process.env.KAFKA_SASL_ENABLED === 'true',
|
||||||
|
saslMechanism: process.env.KAFKA_SASL_MECHANISM || 'plain',
|
||||||
|
saslUsername: process.env.KAFKA_SASL_USERNAME || 'blwmomo',
|
||||||
|
saslPassword: process.env.KAFKA_SASL_PASSWORD || 'blwmomo',
|
||||||
|
sslEnabled: process.env.KAFKA_SSL_ENABLED === 'true',
|
||||||
|
consumerInstances: parseInt(process.env.KAFKA_CONSUMER_INSTANCES || '1'),
|
||||||
|
maxInFlight: parseInt(process.env.KAFKA_MAX_IN_FLIGHT || '5000'),
|
||||||
|
batchSize: parseInt(process.env.KAFKA_BATCH_SIZE || '1000'),
|
||||||
|
batchTimeoutMs: parseInt(process.env.KAFKA_BATCH_TIMEOUT_MS || '20'),
|
||||||
|
commitIntervalMs: parseInt(process.env.KAFKA_COMMIT_INTERVAL_MS || '200'),
|
||||||
|
commitOnAttempt: process.env.KAFKA_COMMIT_ON_ATTEMPT === 'true',
|
||||||
|
fetchMaxBytes: parseInt(process.env.KAFKA_FETCH_MAX_BYTES || '10485760'),
|
||||||
|
fetchMaxWaitMs: parseInt(process.env.KAFKA_FETCH_MAX_WAIT_MS || '100'),
|
||||||
|
fetchMinBytes: parseInt(process.env.KAFKA_FETCH_MIN_BYTES || '1')
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
g5: {
|
||||||
|
host: process.env.POSTGRES_HOST_G5 || '10.8.8.80',
|
||||||
|
port: parseInt(process.env.POSTGRES_PORT_G5 || '5434'),
|
||||||
|
database: process.env.POSTGRES_DATABASE_G5 || 'log_platform',
|
||||||
|
user: process.env.POSTGRES_USER_G5 || 'log_admin',
|
||||||
|
password: process.env.POSTGRES_PASSWORD_G5 || 'H3IkLUt8K!x',
|
||||||
|
idleTimeoutMs: parseInt(process.env.POSTGRES_IDLE_TIMEOUT_MS_G5 || '30000'),
|
||||||
|
maxConnections: parseInt(process.env.POSTGRES_MAX_CONNECTIONS_G5 || '2'),
|
||||||
|
schema: process.env.DB_SCHEMA_G5 || 'rcu_upgrade',
|
||||||
|
table: process.env.DB_TABLE_G5 || 'rcu_upgrade_events_g5'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
host: process.env.REDIS_HOST || '10.8.8.109',
|
||||||
|
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||||
|
password: process.env.REDIS_PASSWORD || '',
|
||||||
|
db: parseInt(process.env.REDIS_DB || '15'),
|
||||||
|
connectTimeoutMs: parseInt(process.env.REDIS_CONNECT_TIMEOUT_MS || '5000'),
|
||||||
|
projectName: process.env.REDIS_PROJECT_NAME || 'bls-onoffline'
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
dbWriteIntervalMs: 1000, // 限制数据库写入频率为每秒最多1次
|
||||||
|
batchSize: 1000 // 批处理大小
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
64
bls-upgrade-backend/src/db/databaseManager.js
Normal file
64
bls-upgrade-backend/src/db/databaseManager.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
import config from '../config/config.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
class DatabaseManager {
|
||||||
|
constructor() {
|
||||||
|
this.pools = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// 初始化G5数据库连接池
|
||||||
|
this.pools.g5 = new Pool({
|
||||||
|
host: config.database.g5.host,
|
||||||
|
port: config.database.g5.port,
|
||||||
|
database: config.database.g5.database,
|
||||||
|
user: config.database.g5.user,
|
||||||
|
password: config.database.g5.password,
|
||||||
|
max: config.database.g5.maxConnections,
|
||||||
|
idleTimeoutMillis: config.database.g5.idleTimeoutMs
|
||||||
|
});
|
||||||
|
|
||||||
|
// 测试连接
|
||||||
|
this.pools.g5.connect((err, client, release) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Error connecting to G5 database:', { error: err.message });
|
||||||
|
} else {
|
||||||
|
logger.info('Successfully connected to G5 database');
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听错误
|
||||||
|
this.pools.g5.on('error', (err) => {
|
||||||
|
logger.error('Unexpected error on G5 database connection pool:', { error: err.message });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getPool(dbName) {
|
||||||
|
return this.pools[dbName];
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(dbName, text, params) {
|
||||||
|
const pool = this.getPool(dbName);
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error(`Database pool ${dbName} not initialized`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
return await client.query(text, params);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(this.pools).map(pool => pool.end())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseManager = new DatabaseManager();
|
||||||
|
export default databaseManager;
|
||||||
58
bls-upgrade-backend/src/index.js
Normal file
58
bls-upgrade-backend/src/index.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import config from './config/config.js';
|
||||||
|
import databaseManager from './db/databaseManager.js';
|
||||||
|
import KafkaConsumer from './kafka/consumer.js';
|
||||||
|
import dataProcessor from './processor/index.js';
|
||||||
|
import logger from './utils/logger.js';
|
||||||
|
|
||||||
|
class App {
|
||||||
|
constructor() {
|
||||||
|
this.consumer = null;
|
||||||
|
this.isShuttingDown = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// 初始化数据库连接
|
||||||
|
databaseManager.init();
|
||||||
|
|
||||||
|
// 初始化Kafka消费者
|
||||||
|
this.consumer = new KafkaConsumer();
|
||||||
|
this.consumer.onMessage = (message) => dataProcessor.processMessage(message);
|
||||||
|
this.consumer.init();
|
||||||
|
|
||||||
|
// 监听进程终止信号
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
await this.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
await this.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`BLS Upgrade Backend service started on port ${config.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
if (this.isShuttingDown) return;
|
||||||
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
|
logger.info('Shutting down BLS Upgrade Backend service...');
|
||||||
|
|
||||||
|
// 确保所有数据都被写入
|
||||||
|
await dataProcessor.flush();
|
||||||
|
|
||||||
|
// 关闭Kafka消费者
|
||||||
|
if (this.consumer) {
|
||||||
|
await this.consumer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭数据库连接
|
||||||
|
await databaseManager.close();
|
||||||
|
|
||||||
|
logger.info('Service shutdown completed');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动应用
|
||||||
|
const app = new App();
|
||||||
|
app.init();
|
||||||
191
bls-upgrade-backend/src/kafka/consumer.js
Normal file
191
bls-upgrade-backend/src/kafka/consumer.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import kafka from 'kafka-node';
|
||||||
|
import config from '../config/config.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
import { OffsetTracker } from './offsetTracker.js';
|
||||||
|
|
||||||
|
const { ConsumerGroup } = kafka;
|
||||||
|
|
||||||
|
class KafkaConsumer {
|
||||||
|
constructor() {
|
||||||
|
this.consumer = null;
|
||||||
|
this.tracker = new OffsetTracker();
|
||||||
|
this.pendingCommits = new Map();
|
||||||
|
this.commitTimer = null;
|
||||||
|
this.inFlight = 0;
|
||||||
|
this.maxInFlight = Number.isFinite(config.kafka.maxInFlight) ? config.kafka.maxInFlight : 5000;
|
||||||
|
this.commitIntervalMs = Number.isFinite(config.kafka.commitIntervalMs) ? config.kafka.commitIntervalMs : 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const kafkaConfig = {
|
||||||
|
kafkaHost: config.kafka.brokers,
|
||||||
|
clientId: config.kafka.clientId,
|
||||||
|
groupId: config.kafka.groupId,
|
||||||
|
fromOffset: config.kafka.fromOffset,
|
||||||
|
protocol: ['roundrobin'],
|
||||||
|
outOfRangeOffset: 'latest',
|
||||||
|
autoCommit: config.kafka.autoCommit,
|
||||||
|
autoCommitIntervalMs: config.kafka.autoCommitIntervalMs,
|
||||||
|
fetchMaxBytes: config.kafka.fetchMaxBytes,
|
||||||
|
fetchMaxWaitMs: config.kafka.fetchMaxWaitMs,
|
||||||
|
fetchMinBytes: config.kafka.fetchMinBytes,
|
||||||
|
sasl: config.kafka.saslEnabled ? {
|
||||||
|
mechanism: config.kafka.saslMechanism,
|
||||||
|
username: config.kafka.saslUsername,
|
||||||
|
password: config.kafka.saslPassword
|
||||||
|
} : undefined,
|
||||||
|
ssl: config.kafka.sslEnabled,
|
||||||
|
connectTimeout: 10000,
|
||||||
|
requestTimeout: 10000
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('Initializing Kafka consumer with config:', {
|
||||||
|
kafkaHost: config.kafka.brokers,
|
||||||
|
clientId: config.kafka.clientId,
|
||||||
|
groupId: config.kafka.groupId,
|
||||||
|
topics: config.kafka.topics,
|
||||||
|
fromOffset: config.kafka.fromOffset,
|
||||||
|
saslEnabled: config.kafka.saslEnabled
|
||||||
|
});
|
||||||
|
|
||||||
|
const topics = config.kafka.topics.split(',').map(topic => topic.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
this.consumer = new ConsumerGroup(kafkaConfig, topics);
|
||||||
|
|
||||||
|
this.consumer.on('connect', () => {
|
||||||
|
logger.info('Kafka consumer connected', {
|
||||||
|
groupId: config.kafka.groupId,
|
||||||
|
topics
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.consumer.on('rebalancing', () => {
|
||||||
|
logger.info('Kafka consumer rebalancing');
|
||||||
|
this.tracker.clear();
|
||||||
|
this.pendingCommits.clear();
|
||||||
|
if (this.commitTimer) {
|
||||||
|
clearTimeout(this.commitTimer);
|
||||||
|
this.commitTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.consumer.on('rebalanced', () => {
|
||||||
|
logger.info('Kafka consumer rebalanced');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.consumer.on('message', (message) => {
|
||||||
|
logger.debug('Received Kafka message:', { messageId: message.offset });
|
||||||
|
|
||||||
|
this.inFlight += 1;
|
||||||
|
this.tracker.add(message.topic, message.partition, message.offset);
|
||||||
|
if (this.inFlight >= this.maxInFlight && this.consumer.pause) {
|
||||||
|
this.consumer.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.resolve(this.onMessage(message))
|
||||||
|
.then(() => {
|
||||||
|
if (!config.kafka.autoCommit) {
|
||||||
|
const commitOffset = this.tracker.markDone(message.topic, message.partition, message.offset);
|
||||||
|
if (commitOffset !== null) {
|
||||||
|
const key = `${message.topic}-${message.partition}`;
|
||||||
|
this.pendingCommits.set(key, {
|
||||||
|
topic: message.topic,
|
||||||
|
partition: message.partition,
|
||||||
|
offset: commitOffset,
|
||||||
|
metadata: 'm'
|
||||||
|
});
|
||||||
|
this.scheduleCommitFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error('Kafka message handling failed, skip commit', {
|
||||||
|
error: err.message,
|
||||||
|
topic: message.topic,
|
||||||
|
partition: message.partition,
|
||||||
|
offset: message.offset
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.inFlight -= 1;
|
||||||
|
if (this.inFlight < this.maxInFlight && this.consumer.resume) {
|
||||||
|
this.consumer.resume();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.consumer.on('error', (err) => {
|
||||||
|
logger.error('Kafka consumer error:', { error: err.message, stack: err.stack });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.consumer.on('offsetOutOfRange', (topic) => {
|
||||||
|
logger.warn('Kafka offset out of range:', { topic: topic.topic, partition: topic.partition });
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Kafka consumer initialized');
|
||||||
|
|
||||||
|
this.consumer.on('close', () => {
|
||||||
|
logger.info('Kafka consumer closed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage(message) {
|
||||||
|
// 子类实现
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleCommitFlush() {
|
||||||
|
if (this.commitTimer) return;
|
||||||
|
this.commitTimer = setTimeout(() => {
|
||||||
|
this.commitTimer = null;
|
||||||
|
this.flushCommits();
|
||||||
|
}, this.commitIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
flushCommits() {
|
||||||
|
if (!this.consumer || this.pendingCommits.size === 0) return;
|
||||||
|
const batch = this.pendingCommits;
|
||||||
|
this.pendingCommits = new Map();
|
||||||
|
|
||||||
|
this.consumer.sendOffsetCommitRequest(Array.from(batch.values()), (err) => {
|
||||||
|
if (err) {
|
||||||
|
for (const [k, v] of batch.entries()) {
|
||||||
|
this.pendingCommits.set(k, v);
|
||||||
|
}
|
||||||
|
logger.error('Failed to commit Kafka offsets', {
|
||||||
|
error: err.message,
|
||||||
|
groupId: config.kafka.groupId,
|
||||||
|
count: batch.size
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Kafka offsets committed', {
|
||||||
|
groupId: config.kafka.groupId,
|
||||||
|
count: batch.size,
|
||||||
|
commits: Array.from(batch.values())
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (this.commitTimer) {
|
||||||
|
clearTimeout(this.commitTimer);
|
||||||
|
this.commitTimer = null;
|
||||||
|
}
|
||||||
|
this.flushCommits();
|
||||||
|
|
||||||
|
if (!this.consumer) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.consumer.close(true, () => {
|
||||||
|
logger.info('Kafka consumer closed');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KafkaConsumer;
|
||||||
53
bls-upgrade-backend/src/kafka/offsetTracker.js
Normal file
53
bls-upgrade-backend/src/kafka/offsetTracker.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
class OffsetTracker {
|
||||||
|
constructor() {
|
||||||
|
this.partitions = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
add(topic, partition, offset) {
|
||||||
|
const key = `${topic}-${partition}`;
|
||||||
|
if (!this.partitions.has(key)) {
|
||||||
|
this.partitions.set(key, { nextCommitOffset: null, done: new Set() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = this.partitions.get(key);
|
||||||
|
const numericOffset = Number(offset);
|
||||||
|
if (!Number.isFinite(numericOffset)) return;
|
||||||
|
|
||||||
|
if (state.nextCommitOffset === null) {
|
||||||
|
state.nextCommitOffset = numericOffset;
|
||||||
|
} else if (numericOffset < state.nextCommitOffset) {
|
||||||
|
state.nextCommitOffset = numericOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markDone(topic, partition, offset) {
|
||||||
|
const key = `${topic}-${partition}`;
|
||||||
|
const state = this.partitions.get(key);
|
||||||
|
if (!state) return null;
|
||||||
|
|
||||||
|
const numericOffset = Number(offset);
|
||||||
|
if (!Number.isFinite(numericOffset)) return null;
|
||||||
|
|
||||||
|
state.done.add(numericOffset);
|
||||||
|
|
||||||
|
if (state.nextCommitOffset === null) {
|
||||||
|
state.nextCommitOffset = numericOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
let advanced = false;
|
||||||
|
while (state.nextCommitOffset !== null && state.done.has(state.nextCommitOffset)) {
|
||||||
|
state.done.delete(state.nextCommitOffset);
|
||||||
|
state.nextCommitOffset += 1;
|
||||||
|
advanced = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!advanced) return null;
|
||||||
|
return state.nextCommitOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.partitions.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { OffsetTracker };
|
||||||
234
bls-upgrade-backend/src/processor/index.js
Normal file
234
bls-upgrade-backend/src/processor/index.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import config from '../config/config.js';
|
||||||
|
import databaseManager from '../db/databaseManager.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
class DataProcessor {
|
||||||
|
constructor() {
|
||||||
|
this.batch = [];
|
||||||
|
this.lastWriteTime = 0;
|
||||||
|
this.flushTimer = null;
|
||||||
|
this.dbRetryDelayMs = 1000;
|
||||||
|
this.dbRetryMaxAttempts = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
async processMessage(message) {
|
||||||
|
try {
|
||||||
|
const rawValue = Buffer.isBuffer(message.value)
|
||||||
|
? message.value.toString('utf8')
|
||||||
|
: String(message.value);
|
||||||
|
const payload = JSON.parse(rawValue);
|
||||||
|
const processedData = this.validateAndTransform(payload);
|
||||||
|
|
||||||
|
const writeAck = new Promise((resolve, reject) => {
|
||||||
|
this.batch.push({
|
||||||
|
data: processedData,
|
||||||
|
meta: {
|
||||||
|
topic: message.topic,
|
||||||
|
partition: message.partition,
|
||||||
|
offset: message.offset
|
||||||
|
},
|
||||||
|
resolve,
|
||||||
|
reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Message accepted into batch', {
|
||||||
|
topic: message.topic,
|
||||||
|
partition: message.partition,
|
||||||
|
offset: message.offset,
|
||||||
|
currentBatchSize: this.batch.length
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scheduleFlush();
|
||||||
|
|
||||||
|
// 检查是否需要立即写入数据库
|
||||||
|
await this.checkAndWriteBatch();
|
||||||
|
await writeAck;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing message:', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateAndTransform(data) {
|
||||||
|
const cleanString = (str) => {
|
||||||
|
if (str === null || str === undefined) return null;
|
||||||
|
return String(str).replace(/\0/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const processed = {
|
||||||
|
ts_ms: typeof data.ts_ms === 'number' ? data.ts_ms : (parseInt(data.ts_ms) || 0),
|
||||||
|
hotel_id: this.validateHotelId(data.hotel_id),
|
||||||
|
room_id: cleanString(data.room_id) || '',
|
||||||
|
device_id: cleanString(data.device_id) || '',
|
||||||
|
is_send: parseInt(data.is_send) || 0,
|
||||||
|
udp_raw: data.udp_raw ? cleanString(Buffer.from(data.udp_raw).toString()) : null,
|
||||||
|
extra: data.extra ? JSON.stringify(data.extra) : null,
|
||||||
|
ip: cleanString(data.remote_endpoint) || '',
|
||||||
|
md5: cleanString(data.md5) || '',
|
||||||
|
partition: parseInt(data.partition) || null,
|
||||||
|
file_type: parseInt(data.file_type) || null,
|
||||||
|
file_path: cleanString(data.file_path) || '',
|
||||||
|
upgrade_state: parseInt(data.upgrade_state) || null,
|
||||||
|
app_version: cleanString(data.app_version) || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateHotelId(hotelId) {
|
||||||
|
const id = parseInt(hotelId);
|
||||||
|
// 检查是否在int2范围内 (-32768 到 32767)
|
||||||
|
if (isNaN(id) || id < -32768 || id > 32767) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAndWriteBatch() {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastWrite = now - this.lastWriteTime;
|
||||||
|
|
||||||
|
// 检查是否达到批处理大小或时间间隔
|
||||||
|
if (this.batch.length >= config.performance.batchSize || timeSinceLastWrite >= config.performance.dbWriteIntervalMs) {
|
||||||
|
await this.writeBatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleFlush() {
|
||||||
|
if (this.flushTimer) return;
|
||||||
|
this.flushTimer = setTimeout(() => {
|
||||||
|
this.flushTimer = null;
|
||||||
|
this.writeBatch().catch((error) => {
|
||||||
|
logger.error('Error in scheduled batch flush:', { error: error.message });
|
||||||
|
});
|
||||||
|
}, config.performance.dbWriteIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
isRetryableDbError(err) {
|
||||||
|
const code = err?.code;
|
||||||
|
if (typeof code === 'string') {
|
||||||
|
const retryableCodes = new Set([
|
||||||
|
'ECONNREFUSED',
|
||||||
|
'ECONNRESET',
|
||||||
|
'EPIPE',
|
||||||
|
'ETIMEDOUT',
|
||||||
|
'ENOTFOUND',
|
||||||
|
'EHOSTUNREACH',
|
||||||
|
'ENETUNREACH',
|
||||||
|
'57P03',
|
||||||
|
'08006',
|
||||||
|
'08001',
|
||||||
|
'08000',
|
||||||
|
'08003'
|
||||||
|
]);
|
||||||
|
if (retryableCodes.has(code)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = typeof err?.message === 'string' ? err.message.toLowerCase() : '';
|
||||||
|
return (
|
||||||
|
message.includes('connection timeout') ||
|
||||||
|
message.includes('connection terminated') ||
|
||||||
|
message.includes('connection refused') ||
|
||||||
|
message.includes('econnrefused') ||
|
||||||
|
message.includes('econnreset') ||
|
||||||
|
message.includes('etimedout') ||
|
||||||
|
message.includes('could not connect') ||
|
||||||
|
message.includes('the database system is starting up')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeQueryWithRetry(query, params) {
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await databaseManager.query('g5', query, params);
|
||||||
|
} catch (error) {
|
||||||
|
attempt += 1;
|
||||||
|
if (!this.isRetryableDbError(error) || attempt > this.dbRetryMaxAttempts) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.warn('Retrying G5 batch write after transient DB error', {
|
||||||
|
attempt,
|
||||||
|
maxAttempts: this.dbRetryMaxAttempts,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, this.dbRetryDelayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeBatch() {
|
||||||
|
if (this.batch.length === 0) return;
|
||||||
|
if (this.flushTimer) {
|
||||||
|
clearTimeout(this.flushTimer);
|
||||||
|
this.flushTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = [...this.batch];
|
||||||
|
this.batch = [];
|
||||||
|
this.lastWriteTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('Flushing batch to G5 database', {
|
||||||
|
batchSize: batch.length,
|
||||||
|
first: batch[0]?.meta,
|
||||||
|
last: batch[batch.length - 1]?.meta
|
||||||
|
});
|
||||||
|
|
||||||
|
// 构建批量插入语句
|
||||||
|
const values = batch.map(item => [
|
||||||
|
item.data.ts_ms,
|
||||||
|
item.data.hotel_id,
|
||||||
|
item.data.room_id,
|
||||||
|
item.data.device_id,
|
||||||
|
item.data.is_send,
|
||||||
|
item.data.udp_raw,
|
||||||
|
item.data.extra,
|
||||||
|
item.data.ip,
|
||||||
|
item.data.md5,
|
||||||
|
item.data.partition,
|
||||||
|
item.data.file_type,
|
||||||
|
item.data.file_path,
|
||||||
|
item.data.upgrade_state,
|
||||||
|
item.data.app_version
|
||||||
|
]);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO ${config.database.g5.schema}.${config.database.g5.table}
|
||||||
|
(ts_ms, hotel_id, room_id, device_id, is_send, udp_raw, extra, ip, md5, partition, file_type, file_path, upgrade_state, app_version)
|
||||||
|
VALUES ${values.map((_, i) => `($${i * 14 + 1}, $${i * 14 + 2}, $${i * 14 + 3}, $${i * 14 + 4}, $${i * 14 + 5}, $${i * 14 + 6}, $${i * 14 + 7}, $${i * 14 + 8}, $${i * 14 + 9}, $${i * 14 + 10}, $${i * 14 + 11}, $${i * 14 + 12}, $${i * 14 + 13}, $${i * 14 + 14})`).join(', ')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 扁平化values数组
|
||||||
|
const params = values.flat();
|
||||||
|
|
||||||
|
await this.executeQueryWithRetry(query, params);
|
||||||
|
logger.info('Batch write success', {
|
||||||
|
batchSize: batch.length,
|
||||||
|
first: batch[0]?.meta,
|
||||||
|
last: batch[batch.length - 1]?.meta
|
||||||
|
});
|
||||||
|
batch.forEach(item => item.resolve());
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error writing batch to database:', {
|
||||||
|
error: error.message,
|
||||||
|
batchSize: batch.length,
|
||||||
|
first: batch[0]?.meta,
|
||||||
|
last: batch[batch.length - 1]?.meta
|
||||||
|
});
|
||||||
|
batch.forEach(item => item.reject(error));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async flush() {
|
||||||
|
// 确保所有数据都被写入
|
||||||
|
if (this.batch.length > 0) {
|
||||||
|
await this.writeBatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataProcessor = new DataProcessor();
|
||||||
|
export default dataProcessor;
|
||||||
13
bls-upgrade-backend/src/test-consumer.js
Normal file
13
bls-upgrade-backend/src/test-consumer.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const baseGroupId = process.env.KAFKA_GROUP_ID || 'bls-upgrade-consumer';
|
||||||
|
const testGroupId = process.env.KAFKA_TEST_GROUP_ID || `${baseGroupId}-test-${Date.now()}`;
|
||||||
|
|
||||||
|
process.env.KAFKA_GROUP_ID = testGroupId;
|
||||||
|
process.env.KAFKA_FROM_OFFSET = process.env.KAFKA_FROM_OFFSET || 'earliest';
|
||||||
|
|
||||||
|
console.log(`[test-consumer] groupId=${process.env.KAFKA_GROUP_ID}, fromOffset=${process.env.KAFKA_FROM_OFFSET}`);
|
||||||
|
|
||||||
|
await import('./index.js');
|
||||||
44
bls-upgrade-backend/src/utils/logger.js
Normal file
44
bls-upgrade-backend/src/utils/logger.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import config from '../config/config.js';
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
constructor() {
|
||||||
|
this.logLevel = config.logLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(level, message, data = {}) {
|
||||||
|
const levels = ['debug', 'info', 'warn', 'error'];
|
||||||
|
const levelIndex = levels.indexOf(level);
|
||||||
|
const configLevelIndex = levels.indexOf(this.logLevel);
|
||||||
|
|
||||||
|
if (levelIndex >= configLevelIndex) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const logMessage = {
|
||||||
|
timestamp,
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
data
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(logMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message, data = {}) {
|
||||||
|
this.log('debug', message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message, data = {}) {
|
||||||
|
this.log('info', message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message, data = {}) {
|
||||||
|
this.log('warn', message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message, data = {}) {
|
||||||
|
this.log('error', message, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = new Logger();
|
||||||
|
export default logger;
|
||||||
53
docs/project.md
Normal file
53
docs/project.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# 0x68命令
|
||||||
|
## 模式
|
||||||
|
- rcu_upgrade
|
||||||
|
### 数据表
|
||||||
|
- rcu_upgrade_events_g5
|
||||||
|
|
||||||
|
#### 基础字段
|
||||||
|
| 字段名 | 类型 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| guid | int8 | 8位整数,由数据库自己生成 |
|
||||||
|
| ts_ms | int8 | 事件发生的时间戳(毫秒级 Unix 时间),作为分区键和主键的一部分。 |
|
||||||
|
| hotel_id | int2 | 酒店Code,smallint 类型,范围 [0, 32767],标识所属酒店。 |
|
||||||
|
| room_id | varchar(50) | 房间号,字符串类型,长度 1~50,标识具体房间。 |
|
||||||
|
| device_id | varchar(64) | 设备唯一标识符,最长64字符,以CRICS拼接字段为准。 |
|
||||||
|
| write_ts_ms | int8 | 写入数据库的时间戳(毫秒级 Unix 时间) |
|
||||||
|
|
||||||
|
#### 信息字段
|
||||||
|
| 字段名 | 类型 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| is_send | int2 | 1:下发,0:上报 默认0 |
|
||||||
|
| udp_raw | text | UDP原始数据(使用Base64编码) |
|
||||||
|
| extra | jsonb | 扩展字段(JSON格式) |
|
||||||
|
|
||||||
|
#### 数据字段
|
||||||
|
| 字段名 | 类型 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ip | varchar(21) | 升级IP+port |
|
||||||
|
| md5 | varchar(255) | 升级MD5校验值 |
|
||||||
|
| partition | int4 | 升级总块数 |
|
||||||
|
| file_type | int2 | 升级文件类型 |
|
||||||
|
| file_path | varchar(255) | 升级路径 |
|
||||||
|
| upgrade_state | int2 | 升级状态 |
|
||||||
|
| app_version | varchar(255) | 固件版本 |
|
||||||
|
|
||||||
|
|
||||||
|
#### 生产服务器的推送的数据用于kafka的C#类:
|
||||||
|
public struct Upgrade_Log
|
||||||
|
{
|
||||||
|
public string hotel_id { get; set; }
|
||||||
|
public string device_id { get; set; }
|
||||||
|
public string room_id { get; set; }
|
||||||
|
public string ts_ms { get; set; }
|
||||||
|
public int is_send { get; set; }
|
||||||
|
public byte[] udp_raw { get; set; }
|
||||||
|
public object extra { get; set; }
|
||||||
|
public string remote_endpoint { get; set; }
|
||||||
|
public string md5 { get; set; }
|
||||||
|
public int partition { get; set; }
|
||||||
|
public int file_type { get; set; }
|
||||||
|
public string file_path { get; set; }
|
||||||
|
public int upgrade_state { get; set; }
|
||||||
|
public string app_version { get; set; }
|
||||||
|
}
|
||||||
81
docs/rcu_upgrade_events_g5.sql
Normal file
81
docs/rcu_upgrade_events_g5.sql
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
Navicat Premium Dump SQL
|
||||||
|
|
||||||
|
Source Server : FnOS 80
|
||||||
|
Source Server Type : PostgreSQL
|
||||||
|
Source Server Version : 150017 (150017)
|
||||||
|
Source Host : 10.8.8.80:5434
|
||||||
|
Source Catalog : log_platform
|
||||||
|
Source Schema : rcu_upgrade
|
||||||
|
|
||||||
|
Target Server Type : PostgreSQL
|
||||||
|
Target Server Version : 150017 (150017)
|
||||||
|
File Encoding : 65001
|
||||||
|
|
||||||
|
Date: 17/03/2026 09:38:29
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table structure for rcu_upgrade_events_g5
|
||||||
|
-- ----------------------------
|
||||||
|
DROP TABLE IF EXISTS "rcu_upgrade"."rcu_upgrade_events_g5";
|
||||||
|
CREATE TABLE "rcu_upgrade"."rcu_upgrade_events_g5" (
|
||||||
|
"guid" int8 NOT NULL GENERATED BY DEFAULT AS IDENTITY (
|
||||||
|
INCREMENT 1
|
||||||
|
MINVALUE 1
|
||||||
|
MAXVALUE 9223372036854775807
|
||||||
|
START 1
|
||||||
|
CACHE 1
|
||||||
|
),
|
||||||
|
"ts_ms" int8 NOT NULL,
|
||||||
|
"hotel_id" int2 NOT NULL,
|
||||||
|
"room_id" varchar(50) COLLATE "pg_catalog"."default" NOT NULL,
|
||||||
|
"device_id" varchar(64) COLLATE "pg_catalog"."default",
|
||||||
|
"write_ts_ms" int8 DEFAULT ((EXTRACT(epoch FROM now()) * (1000)::numeric))::bigint,
|
||||||
|
"is_send" int2 DEFAULT 0,
|
||||||
|
"udp_raw" text COLLATE "pg_catalog"."default",
|
||||||
|
"extra" jsonb,
|
||||||
|
"ip" varchar(21) COLLATE "pg_catalog"."default",
|
||||||
|
"md5" varchar(255) COLLATE "pg_catalog"."default",
|
||||||
|
"partition" int4,
|
||||||
|
"file_type" int2,
|
||||||
|
"file_path" varchar(255) COLLATE "pg_catalog"."default",
|
||||||
|
"upgrade_state" int2,
|
||||||
|
"app_version" varchar(255) COLLATE "pg_catalog"."default"
|
||||||
|
)
|
||||||
|
TABLESPACE "ts_hot"
|
||||||
|
;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Indexes structure for table rcu_upgrade_events_g5
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE INDEX "idx_rcu_upg_g5_ts_appver" ON "rcu_upgrade"."rcu_upgrade_events_g5" USING btree (
|
||||||
|
"ts_ms" "pg_catalog"."int8_ops" DESC NULLS FIRST,
|
||||||
|
"app_version" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
|
||||||
|
) WHERE app_version IS NOT NULL;
|
||||||
|
CREATE INDEX "idx_rcu_upg_g5_ts_device" ON "rcu_upgrade"."rcu_upgrade_events_g5" USING btree (
|
||||||
|
"ts_ms" "pg_catalog"."int8_ops" DESC NULLS FIRST,
|
||||||
|
"device_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
|
||||||
|
) WHERE device_id IS NOT NULL;
|
||||||
|
CREATE INDEX "idx_rcu_upg_g5_ts_filetype" ON "rcu_upgrade"."rcu_upgrade_events_g5" USING btree (
|
||||||
|
"ts_ms" "pg_catalog"."int8_ops" DESC NULLS FIRST,
|
||||||
|
"file_type" "pg_catalog"."int2_ops" ASC NULLS LAST
|
||||||
|
);
|
||||||
|
CREATE INDEX "idx_rcu_upg_g5_ts_hotel_room" ON "rcu_upgrade"."rcu_upgrade_events_g5" USING btree (
|
||||||
|
"ts_ms" "pg_catalog"."int8_ops" DESC NULLS FIRST,
|
||||||
|
"hotel_id" "pg_catalog"."int2_ops" ASC NULLS LAST,
|
||||||
|
"room_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
|
||||||
|
);
|
||||||
|
CREATE INDEX "idx_rcu_upg_g5_ts_issend" ON "rcu_upgrade"."rcu_upgrade_events_g5" USING btree (
|
||||||
|
"ts_ms" "pg_catalog"."int8_ops" DESC NULLS FIRST,
|
||||||
|
"is_send" "pg_catalog"."int2_ops" ASC NULLS LAST
|
||||||
|
);
|
||||||
|
CREATE INDEX "rcu_upgrade_events_g5_ts_ms_idx" ON "rcu_upgrade"."rcu_upgrade_events_g5" USING btree (
|
||||||
|
"ts_ms" "pg_catalog"."int8_ops" DESC NULLS FIRST
|
||||||
|
) TABLESPACE "ts_hot";
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Primary Key structure for table rcu_upgrade_events_g5
|
||||||
|
-- ----------------------------
|
||||||
|
ALTER TABLE "rcu_upgrade"."rcu_upgrade_events_g5" ADD CONSTRAINT "rcu_upgrade_events_g5_pkey" PRIMARY KEY ("ts_ms", "guid");
|
||||||
Reference in New Issue
Block a user