diff --git a/start/src/main/resources/bootstrap.yml b/start/src/main/resources/bootstrap.yml index 1f3cdb8..dfff433 100644 --- a/start/src/main/resources/bootstrap.yml +++ b/start/src/main/resources/bootstrap.yml @@ -3,5 +3,6 @@ spring: import: # - classpath:nacos-prod.yml - classpath:nacos.yml + - classpath:kafka.yml - classpath:sdk.yml - classpath:swagger.yml diff --git a/start/src/main/resources/kafka.yml b/start/src/main/resources/kafka.yml new file mode 100644 index 0000000..36b8439 --- /dev/null +++ b/start/src/main/resources/kafka.yml @@ -0,0 +1,25 @@ +spring: + kafka: + bootstrap-servers: ${PERSONNEL_POSITIONING_KAFKA_BOOTSTRAP_SERVERS:192.168.193.23:9092} + consumer: + auto-offset-reset: latest + enable-auto-commit: false + key-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer + value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer + max-poll-records: 20 + properties: + allow.auto.create.topics: false + security.protocol: ${PERSONNEL_POSITIONING_KAFKA_SECURITY_PROTOCOL:PLAINTEXT} + sasl.mechanism: ${PERSONNEL_POSITIONING_KAFKA_SASL_MECHANISM:PLAIN} + listener: + ack-mode: manual + missing-topics-fatal: false + idle-event-interval: ${PERSONNEL_POSITIONING_KAFKA_IDLE_EVENT_INTERVAL:60s} + +personnel-positioning: + kafka: + location: + enabled: ${PERSONNEL_POSITIONING_KAFKA_LOCATION_ENABLED:false} + topic: ${PERSONNEL_POSITIONING_KAFKA_LOCATION_TOPIC:point_push} + group-id: ${PERSONNEL_POSITIONING_KAFKA_LOCATION_GROUP_ID:personnel-position-location-probe} + max-log-bytes: ${PERSONNEL_POSITIONING_KAFKA_LOCATION_MAX_LOG_BYTES:16384} diff --git a/web-app/pom.xml b/web-app/pom.xml index 8b74104..951ce69 100644 --- a/web-app/pom.xml +++ b/web-app/pom.xml @@ -21,6 +21,10 @@ com.zcloud.personnel.positioning web-infrastructure + + org.springframework.kafka + spring-kafka + org.junit.jupiter junit-jupiter diff --git a/web-app/src/main/java/com/zcloud/personnel/positioning/integration/kafka/KafkaConsumerLifecycleLogger.java b/web-app/src/main/java/com/zcloud/personnel/positioning/integration/kafka/KafkaConsumerLifecycleLogger.java new file mode 100644 index 0000000..bb8ff08 --- /dev/null +++ b/web-app/src/main/java/com/zcloud/personnel/positioning/integration/kafka/KafkaConsumerLifecycleLogger.java @@ -0,0 +1,187 @@ +package com.zcloud.personnel.positioning.integration.kafka; + +import org.apache.kafka.clients.consumer.Consumer; +import org.apache.kafka.common.TopicPartition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.event.EventListener; +import org.springframework.kafka.event.ConsumerFailedToStartEvent; +import org.springframework.kafka.event.ConsumerStartedEvent; +import org.springframework.kafka.event.ConsumerStartingEvent; +import org.springframework.kafka.event.ConsumerStoppedEvent; +import org.springframework.kafka.event.KafkaEvent; +import org.springframework.kafka.event.ListenerContainerIdleEvent; +import org.springframework.kafka.event.NonResponsiveConsumerEvent; +import org.springframework.kafka.listener.ConsumerAwareRebalanceListener; +import org.springframework.kafka.listener.MessageListenerContainer; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Collection; + +@Component +@ConditionalOnProperty( + prefix = "personnel-positioning.kafka.location", + name = "enabled", + havingValue = "true" +) +public class KafkaConsumerLifecycleLogger implements ConsumerAwareRebalanceListener { + private static final Logger LOGGER = LoggerFactory.getLogger(KafkaConsumerLifecycleLogger.class); + private static final String LOCATION_LISTENER_ID = "personnelPositionLocationProbeListener"; + + private final String bootstrapServers; + private final String securityProtocol; + private final String saslMechanism; + + public KafkaConsumerLifecycleLogger( + @Value("${spring.kafka.bootstrap-servers}") String bootstrapServers, + @Value("${spring.kafka.consumer.properties.security.protocol:PLAINTEXT}") String securityProtocol, + @Value("${spring.kafka.consumer.properties.sasl.mechanism:PLAIN}") String saslMechanism) { + this.bootstrapServers = bootstrapServers; + this.securityProtocol = securityProtocol; + this.saslMechanism = saslMechanism; + } + + @EventListener + public void onConsumerStarting(ConsumerStartingEvent event) { + MessageListenerContainer container = locationContainer(event); + if (container == null) { + return; + } + LOGGER.info( + "Kafka定位消息消费者开始启动,listenerId={}, broker={}, topics={}, groupId={}, " + + "securityProtocol={}, saslMechanism={}", + container.getListenerId(), + bootstrapServers, + Arrays.toString(container.getContainerProperties().getTopics()), + container.getGroupId(), + securityProtocol, + saslMechanism + ); + } + + @EventListener + public void onConsumerStarted(ConsumerStartedEvent event) { + MessageListenerContainer container = locationContainer(event); + if (container != null) { + LOGGER.info( + "Kafka定位消息消费者已启动,等待连接Broker并分配分区," + + "listenerId={}, groupId={}", + container.getListenerId(), + container.getGroupId() + ); + } + } + + @Override + public void onPartitionsAssigned(Consumer consumer, Collection partitions) { + LOGGER.info( + "Kafka定位消息订阅成功,broker={}, groupId={}, partitions={}", + bootstrapServers, + consumer.groupMetadata().groupId(), + partitions + ); + } + + @Override + public void onPartitionsRevokedBeforeCommit(Consumer consumer, Collection partitions) { + LOGGER.warn( + "Kafka定位消息分区已回收,groupId={}, partitions={}", + consumer.groupMetadata().groupId(), + partitions + ); + } + + @Override + public void onPartitionsLost(Consumer consumer, Collection partitions) { + LOGGER.error( + "Kafka定位消息分区意外丢失,groupId={}, partitions={}", + consumer.groupMetadata().groupId(), + partitions + ); + } + + @EventListener + public void onConsumerFailedToStart(ConsumerFailedToStartEvent event) { + MessageListenerContainer container = locationContainer(event); + if (container != null) { + LOGGER.error( + "Kafka定位消息消费者启动失败,listenerId={}, broker={}, groupId={}", + container.getListenerId(), + bootstrapServers, + container.getGroupId() + ); + } + } + + @EventListener + public void onConsumerStopped(ConsumerStoppedEvent event) { + MessageListenerContainer container = locationContainer(event); + if (container == null) { + return; + } + if (ConsumerStoppedEvent.Reason.NORMAL.equals(event.getReason())) { + LOGGER.info( + "Kafka定位消息消费者已正常停止,listenerId={}, groupId={}", + container.getListenerId(), + container.getGroupId() + ); + return; + } + LOGGER.error( + "Kafka定位消息消费者异常停止,listenerId={}, broker={}, groupId={}, reason={}", + container.getListenerId(), + bootstrapServers, + container.getGroupId(), + event.getReason() + ); + } + + @EventListener + public void onNonResponsiveConsumer(NonResponsiveConsumerEvent event) { + if (!isLocationListener(event.getListenerId())) { + return; + } + LOGGER.warn( + "Kafka定位消息消费者无响应,listenerId={}, timeSinceLastPollMs={}, partitions={}", + event.getListenerId(), + event.getTimeSinceLastPoll(), + event.getTopicPartitions() + ); + } + + @EventListener + public void onIdle(ListenerContainerIdleEvent event) { + if (!isLocationListener(event.getListenerId())) { + return; + } + Collection partitions = event.getTopicPartitions(); + if (partitions == null || partitions.isEmpty()) { + LOGGER.warn( + "Kafka定位消息订阅尚未分配分区,listenerId={}, broker={}, idleMs={}", + event.getListenerId(), + bootstrapServers, + event.getIdleTime() + ); + return; + } + LOGGER.info( + "Kafka定位消息订阅正常,但暂未收到新消息,listenerId={}, " + + "idleMs={}, partitions={}", + event.getListenerId(), + event.getIdleTime(), + partitions + ); + } + + private MessageListenerContainer locationContainer(KafkaEvent event) { + MessageListenerContainer container = event.getContainer(MessageListenerContainer.class); + return container != null && isLocationListener(container.getListenerId()) ? container : null; + } + + private boolean isLocationListener(String listenerId) { + return listenerId != null && listenerId.startsWith(LOCATION_LISTENER_ID); + } +} diff --git a/web-app/src/main/java/com/zcloud/personnel/positioning/integration/kafka/LocationKafkaPayloadPreview.java b/web-app/src/main/java/com/zcloud/personnel/positioning/integration/kafka/LocationKafkaPayloadPreview.java new file mode 100644 index 0000000..db91420 --- /dev/null +++ b/web-app/src/main/java/com/zcloud/personnel/positioning/integration/kafka/LocationKafkaPayloadPreview.java @@ -0,0 +1,41 @@ +package com.zcloud.personnel.positioning.integration.kafka; + +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; + +final class LocationKafkaPayloadPreview { + + private LocationKafkaPayloadPreview() { + } + + static String render(byte[] payload, int maxBytes) { + if (payload == null) { + return ""; + } + int previewLength = Math.min(payload.length, Math.max(1, maxBytes)); + byte[] preview = Arrays.copyOf(payload, previewLength); + String suffix = payload.length > previewLength + ? " (truncated, totalBytes=" + payload.length + ")" + : ""; + try { + String text = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(preview)) + .toString(); + return "utf8:" + oneLine(text) + suffix; + } catch (CharacterCodingException ignored) { + return "base64:" + Base64.getEncoder().encodeToString(preview) + suffix; + } + } + + private static String oneLine(String value) { + return value.replace("\r", "\\r") + .replace("\n", "\\n") + .replace("\t", "\\t"); + } +} diff --git a/web-app/src/main/java/com/zcloud/personnel/positioning/integration/kafka/LocationKafkaProbeListener.java b/web-app/src/main/java/com/zcloud/personnel/positioning/integration/kafka/LocationKafkaProbeListener.java new file mode 100644 index 0000000..2cdf589 --- /dev/null +++ b/web-app/src/main/java/com/zcloud/personnel/positioning/integration/kafka/LocationKafkaProbeListener.java @@ -0,0 +1,63 @@ +package com.zcloud.personnel.positioning.integration.kafka; + +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +import java.util.StringJoiner; + +@Component +@ConditionalOnProperty( + prefix = "personnel-positioning.kafka.location", + name = "enabled", + havingValue = "true" +) +public class LocationKafkaProbeListener { + private static final Logger LOGGER = LoggerFactory.getLogger(LocationKafkaProbeListener.class); + + private final int maxLogBytes; + + public LocationKafkaProbeListener( + @Value("${personnel-positioning.kafka.location.max-log-bytes:16384}") int maxLogBytes) { + this.maxLogBytes = Math.max(1, maxLogBytes); + } + + @KafkaListener( + id = "personnelPositionLocationProbeListener", + topics = "${personnel-positioning.kafka.location.topic:point_push}", + groupId = "${personnel-positioning.kafka.location.group-id:personnel-position-location-probe}" + ) + public void onMessage(ConsumerRecord record) { + LOGGER.info( + "收到Kafka定位原始消息,topic={}, partition={}, offset={}, timestamp={}, " + + "timestampType={}, keyBytes={}, valueBytes={}, key={}, headers={}, value={}", + record.topic(), + record.partition(), + record.offset(), + record.timestamp(), + record.timestampType(), + length(record.key()), + length(record.value()), + LocationKafkaPayloadPreview.render(record.key(), maxLogBytes), + renderHeaders(record), + LocationKafkaPayloadPreview.render(record.value(), maxLogBytes) + ); + } + + private int length(byte[] value) { + return value == null ? 0 : value.length; + } + + private String renderHeaders(ConsumerRecord record) { + StringJoiner joiner = new StringJoiner(", ", "[", "]"); + for (Header header : record.headers()) { + joiner.add(header.key() + "=" + LocationKafkaPayloadPreview.render(header.value(), maxLogBytes)); + } + return joiner.toString(); + } +}