Commit 6423f0de authored by Sensor MVP Team's avatar Sensor MVP Team
Browse files

Merge master into main: Add comprehensive project files and documentation

parents d32dec3c fcfb9aec
package com.sensor.bridge;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
/**
* 센서 파서 팩토리
* 센서 타입에 따라 적절한 파서를 생성하고 관리
*/
public class SensorParserFactory {
private static final Logger logger = LoggerFactory.getLogger(SensorParserFactory.class);
// 파서 인스턴스 캐시
private static final Map<String, SensorParser> parserCache = new HashMap<>();
// 기본 파서 인스턴스
private static final DefaultParser defaultParser = new DefaultParser();
static {
// 기본 파서들을 캐시에 등록
registerParser("SHT30", new SHT30Parser());
registerParser("BME680", new BME680Parser());
registerParser("DEFAULT", defaultParser);
}
/**
* 센서 타입에 따른 파서 생성
* @param sensorType 센서 타입
* @return 적절한 센서 파서
*/
public static SensorParser createParser(String sensorType) {
if (sensorType == null || sensorType.trim().isEmpty()) {
logger.warn("센서 타입이 null이거나 비어있음, 기본 파서 사용");
return defaultParser;
}
String normalizedType = sensorType.trim().toUpperCase();
// 캐시된 파서 반환
SensorParser cachedParser = parserCache.get(normalizedType);
if (cachedParser != null) {
logger.debug("캐시된 파서 사용: {}", normalizedType);
return cachedParser;
}
// 새로운 파서 생성
SensorParser newParser = createNewParser(normalizedType);
if (newParser != null) {
registerParser(normalizedType, newParser);
return newParser;
}
// 기본 파서 반환
logger.debug("알 수 없는 센서 타입: {}, 기본 파서 사용", normalizedType);
return defaultParser;
}
/**
* 센서 데이터를 기반으로 최적의 파서 선택
* @param data 센서 데이터
* @return 최적의 센서 파서
*/
public static SensorParser selectBestParser(SensorData data) {
if (data == null) {
return defaultParser;
}
// 각 파서의 품질 점수 계산
Map<String, Double> qualityScores = new HashMap<>();
for (SensorParser parser : parserCache.values()) {
double quality = parser.getParsingQuality(data);
qualityScores.put(parser.getSensorType(), quality);
logger.debug("파서 {} 품질 점수: {}", parser.getSensorType(), quality);
}
// 최고 품질 점수를 가진 파서 선택
String bestParserType = qualityScores.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("DEFAULT");
logger.debug("최적 파서 선택: {} (품질 점수: {})", bestParserType, qualityScores.get(bestParserType));
return parserCache.get(bestParserType);
}
/**
* 새로운 파서 등록
* @param sensorType 센서 타입
* @param parser 센서 파서
*/
public static void registerParser(String sensorType, SensorParser parser) {
if (sensorType != null && parser != null) {
String normalizedType = sensorType.trim().toUpperCase();
parserCache.put(normalizedType, parser);
logger.info("새로운 파서 등록: {} -> {}", normalizedType, parser.getClass().getSimpleName());
}
}
/**
* 등록된 파서 목록 반환
* @return 등록된 파서 타입 목록
*/
public static String[] getRegisteredParserTypes() {
return parserCache.keySet().toArray(new String[0]);
}
/**
* 특정 센서 타입의 파서 존재 여부 확인
* @param sensorType 센서 타입
* @return 파서 존재 여부
*/
public static boolean hasParser(String sensorType) {
if (sensorType == null) {
return false;
}
return parserCache.containsKey(sensorType.trim().toUpperCase());
}
private static SensorParser createNewParser(String sensorType) {
// 센서 타입에 따른 새로운 파서 생성 로직
switch (sensorType) {
case "SHT30":
return new SHT30Parser();
case "BME680":
return new BME680Parser();
case "DHT22":
case "DHT11":
// DHT 시리즈 센서용 파서 (향후 구현)
logger.info("DHT 시리즈 센서 파서는 아직 구현되지 않음, 기본 파서 사용");
return defaultParser;
case "DS18B20":
// DS18B20 온도 센서용 파서 (향후 구현)
logger.info("DS18B20 센서 파서는 아직 구현되지 않음, 기본 파서 사용");
return defaultParser;
default:
logger.debug("지원되지 않는 센서 타입: {}", sensorType);
return null;
}
}
}
package com.sensor.bridge;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.ArrayList;
/**
* 메인 온도 파서
* param.dat 기반의 정확한 온도 파싱 시스템
*/
public class TemperatureParser {
private static final Logger logger = LoggerFactory.getLogger(TemperatureParser.class);
// 센서 타입별 파라미터 정보
private final List<ParamItem> paramItems;
// 기본 파라미터 (rawTem * 10 공식용)
private final ParamItem defaultParamItem;
public TemperatureParser() {
this.paramItems = new ArrayList<>();
this.defaultParamItem = createDefaultParamItem();
initializeDefaultParams();
}
/**
* 온도 데이터 파싱 (메인 메서드)
* @param data 센서 데이터
* @param sensorType 센서 타입 (선택사항)
* @return 파싱된 온도 값 (°C)
*/
public double parseTemperature(SensorData data, String sensorType) {
if (data == null) {
logger.warn("센서 데이터가 null, 기본값 반환");
return defaultParamItem.getDefaultValue();
}
logger.debug("온도 파싱 시작: sensorType={}, rawTem={}, floatValue={}, signedInt32Value={}",
sensorType, data.getRawTem(), data.getFloatValue(), data.getSignedInt32Value());
// 1. 센서 타입별 파서 선택
SensorParser parser = selectParser(sensorType, data);
// 2. param.dat 파라미터 찾기
ParamItem paramItem = findParamItem(sensorType, data);
// 3. 온도 파싱 실행
double temperature = parser.parseTemperature(data, paramItem);
// 4. 결과 검증 및 로깅
logParsingResult(parser, paramItem, temperature, data);
return temperature;
}
/**
* 센서 타입별 파서 선택
* @param sensorType 센서 타입
* @param data 센서 데이터
* @return 선택된 센서 파서
*/
private SensorParser selectParser(String sensorType, SensorData data) {
if (sensorType != null && !sensorType.trim().isEmpty()) {
// 명시된 센서 타입으로 파서 생성
SensorParser parser = SensorParserFactory.createParser(sensorType);
logger.debug("명시된 센서 타입으로 파서 선택: {} -> {}", sensorType, parser.getSensorType());
return parser;
} else {
// 센서 데이터 기반으로 최적 파서 자동 선택
SensorParser parser = SensorParserFactory.selectBestParser(data);
logger.debug("자동 파서 선택: {} (품질 점수: {})",
parser.getSensorType(), parser.getParsingQuality(data));
return parser;
}
}
/**
* param.dat 파라미터 찾기
* @param sensorType 센서 타입
* @param data 센서 데이터
* @return 찾은 파라미터 정보
*/
private ParamItem findParamItem(String sensorType, SensorData data) {
// 1. 센서 타입별 파라미터 검색
if (sensorType != null && !sensorType.trim().isEmpty()) {
for (ParamItem item : paramItems) {
if (sensorType.equalsIgnoreCase(item.getSensorType())) {
logger.debug("센서 타입별 파라미터 발견: {}", item);
return item;
}
}
}
// 2. 데이터 패턴 기반 파라미터 검색
ParamItem patternParam = findParamByDataPattern(data);
if (patternParam != null) {
logger.debug("데이터 패턴 기반 파라미터 발견: {}", patternParam);
return patternParam;
}
// 3. 기본 파라미터 반환
logger.debug("기본 파라미터 사용: {}", defaultParamItem);
return defaultParamItem;
}
/**
* 데이터 패턴 기반 파라미터 찾기
* @param data 센서 데이터
* @return 찾은 파라미터 정보
*/
private ParamItem findParamByDataPattern(SensorData data) {
// rawTem 범위 기반 파라미터 추정
if (data.getRawTem() >= 0 && data.getRawTem() <= 50) {
// 기존 rawTem * 10 공식과 일치하는 파라미터
ParamItem param = new ParamItem("ESTIMATED", "FLOAT", 10.0, 0.0);
param.setDescription("데이터 패턴 기반 추정 (rawTem * 10)");
logger.debug("데이터 패턴 기반 파라미터 추정: {}", param);
return param;
}
// floatValue 범위 기반 파라미터 추정
if (data.getFloatValue() >= -40.0 && data.getFloatValue() <= 80.0) {
ParamItem param = new ParamItem("ESTIMATED", "FLOAT", 1.0, 0.0);
param.setDescription("데이터 패턴 기반 추정 (floatValue 직접 사용)");
logger.debug("데이터 패턴 기반 파라미터 추정: {}", param);
return param;
}
return null;
}
/**
* 파싱 결과 로깅
* @param parser 사용된 파서
* @param paramItem 사용된 파라미터
* @param temperature 파싱된 온도
* @param data 원본 센서 데이터
*/
private void logParsingResult(SensorParser parser, ParamItem paramItem, double temperature, SensorData data) {
logger.info("온도 파싱 완료: parser={}, paramItem={}, temperature={}°C",
parser.getSensorType(), paramItem, temperature);
if (logger.isDebugEnabled()) {
logger.debug("파싱 상세 정보:");
logger.debug(" - 원본 데이터: rawTem={}, floatValue={}, signedInt32Value={}",
data.getRawTem(), data.getFloatValue(), data.getSignedInt32Value());
logger.debug(" - 사용된 파서: {}", parser.getClass().getSimpleName());
logger.debug(" - 파라미터: {}", paramItem);
logger.debug(" - 최종 온도: {}°C", temperature);
}
}
/**
* 새로운 파라미터 추가
* @param paramItem 추가할 파라미터
*/
public void addParamItem(ParamItem paramItem) {
if (paramItem != null) {
paramItems.add(paramItem);
logger.info("새로운 파라미터 추가: {}", paramItem);
}
}
/**
* 파라미터 목록 반환
* @return 등록된 파라미터 목록
*/
public List<ParamItem> getParamItems() {
return new ArrayList<>(paramItems);
}
/**
* 기본 파라미터 생성
* @return 기본 파라미터
*/
private ParamItem createDefaultParamItem() {
ParamItem param = new ParamItem("DEFAULT", "FLOAT", 10.0, 0.0);
param.setDescription("기본 파라미터 (rawTem * 10 공식)");
param.setMinValue(-40.0);
param.setMaxValue(80.0);
param.setDefaultValue(25.0);
return param;
}
/**
* 기본 파라미터 초기화
*/
private void initializeDefaultParams() {
// SHT30 센서용 기본 파라미터
ParamItem sht30Param = new ParamItem("SHT30", "FLOAT", 10.0, 0.0);
sht30Param.setDescription("SHT30 온도 센서 (rawTem * 10)");
sht30Param.setMinValue(-40.0);
sht30Param.setMaxValue(80.0);
sht30Param.setDefaultValue(25.0);
addParamItem(sht30Param);
// BME680 센서용 기본 파라미터
ParamItem bme680Param = new ParamItem("BME680", "FLOAT", 1.0, 0.0);
bme680Param.setDescription("BME680 환경 센서 (floatValue 직접 사용)");
bme680Param.setMinValue(-40.0);
bme680Param.setMaxValue(80.0);
bme680Param.setDefaultValue(25.0);
addParamItem(bme680Param);
logger.info("기본 파라미터 초기화 완료: {}개 등록", paramItems.size());
}
}
# Go 서버 URL
go.server.url=http://localhost:8080
# RSNetDevice param.dat 파일 경로
rs.server.param.file=JavaSDKV2.2.2/param.dat
# 로깅 설정
logging.level.com.sensor.bridge=INFO
logging.level.com.jnrsmcu.sdk=DEBUG
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/sensor-bridge.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/sensor-bridge.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger name="com.sensor.bridge" level="INFO"/>
<logger name="com.jnrsmcu.sdk" level="DEBUG"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>
\ No newline at end of file
package com.sensor.bridge;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import static org.junit.jupiter.api.Assertions.*;
/**
* SensorDataParser 클래스의 단위 테스트
*/
public class SensorDataParserTest {
@BeforeEach
void setUp() {
// 테스트 전 초기화 작업
}
@Test
void testParseTemperature() {
// 테스트 케이스 1: signedInt32Value가 실제 온도인 경우
double temp1 = SensorDataParser.parseTemperature(0.0, 0.0, 524306);
assertEquals(29.1, temp1, 0.1, "온도 변환 테스트 1 실패");
// 테스트 케이스 2: rawTem이 0.1도 단위인 경우
double temp2 = SensorDataParser.parseTemperature(2.91, 0.0, 0);
assertEquals(29.1, temp2, 0.1, "온도 변환 테스트 2 실패");
// 테스트 케이스 3: floatValue가 실제 온도인 경우
double temp3 = SensorDataParser.parseTemperature(0.0, 29.1, 0);
assertEquals(29.1, temp3, 0.1, "온도 변환 테스트 3 실패");
// 테스트 케이스 4: 기본값 반환
double temp4 = SensorDataParser.parseTemperature(0.0, 0.0, 0);
assertEquals(25.0, temp4, 0.1, "온도 기본값 테스트 실패");
}
@Test
void testParseHumidity() {
// 테스트 케이스 1: rawHum이 0.8인 경우 (35.0%로 변환)
double humidity1 = SensorDataParser.parseHumidity(0.8);
assertEquals(35.0, humidity1, 0.1, "습도 변환 테스트 1 실패");
// 테스트 케이스 2: rawHum이 이미 적절한 범위인 경우
double humidity2 = SensorDataParser.parseHumidity(60.0);
assertEquals(60.0, humidity2, 0.1, "습도 변환 테스트 2 실패");
// 테스트 케이스 3: 기본값 반환
double humidity3 = SensorDataParser.parseHumidity(0.0);
assertEquals(60.0, humidity3, 0.1, "습도 기본값 테스트 실패");
}
@Test
void testParsePM10() {
// 테스트 케이스 1: floatValue가 유효한 범위인 경우
double pm10_1 = SensorDataParser.parsePM10(25.5, 0);
assertEquals(25.5, pm10_1, 0.1, "PM10 floatValue 테스트 실패");
// 테스트 케이스 2: signedInt32Value에서 추출
double pm10_2 = SensorDataParser.parsePM10(0.0, 2550); // 2550 / 100 = 25.5
assertEquals(25.5, pm10_2, 0.1, "PM10 signedInt32Value 테스트 실패");
}
@Test
void testParsePM25() {
// 테스트 케이스 1: floatValue가 유효한 범위인 경우
double pm25_1 = SensorDataParser.parsePM25(12.3, 0);
assertEquals(12.3, pm25_1, 0.1, "PM2.5 floatValue 테스트 실패");
// 테스트 케이스 2: signedInt32Value에서 추출 (상위 16비트)
double pm25_2 = SensorDataParser.parsePM25(0.0, 1230 << 16); // 1230 / 100 = 12.3
assertEquals(12.3, pm25_2, 0.1, "PM2.5 signedInt32Value 테스트 실패");
}
@Test
void testParsePressure() {
// 테스트 케이스: signedInt32Value에서 기압 추출
double pressure = SensorDataParser.parsePressure(101325 << 16); // 1013.25 hPa
assertEquals(1013.25, pressure, 0.1, "기압 변환 테스트 실패");
}
@Test
void testParseIllumination() {
// 테스트 케이스 1: floatValue가 유효한 범위인 경우
double illumination1 = SensorDataParser.parseIllumination(500.0, 0);
assertEquals(500.0, illumination1, 0.1, "조도 floatValue 테스트 실패");
// 테스트 케이스 2: unsignedInt32Value에서 추출
double illumination2 = SensorDataParser.parseIllumination(0.0, 50); // 50 * 10 = 500 lux
assertEquals(500.0, illumination2, 0.1, "조도 unsignedInt32Value 테스트 실패");
}
@Test
void testParseTVOC() {
// 테스트 케이스 1: floatValue가 유효한 범위인 경우
double tvoc1 = SensorDataParser.parseTVOC(150.0, 0);
assertEquals(150.0, tvoc1, 0.1, "TVOC floatValue 테스트 실패");
// 테스트 케이스 2: signedInt32Value에서 추출
double tvoc2 = SensorDataParser.parseTVOC(0.0, 150);
assertEquals(150.0, tvoc2, 0.1, "TVOC signedInt32Value 테스트 실패");
}
@Test
void testParseCO2() {
// 테스트 케이스 1: floatValue가 유효한 범위인 경우
double co2_1 = SensorDataParser.parseCO2(800.0, 0);
assertEquals(800.0, co2_1, 0.1, "CO2 floatValue 테스트 실패");
// 테스트 케이스 2: signedInt32Value에서 추출 (상위 16비트)
double co2_2 = SensorDataParser.parseCO2(0.0, 400 << 16); // 400 + 400 = 800 ppm
assertEquals(800.0, co2_2, 0.1, "CO2 signedInt32Value 테스트 실패");
}
@Test
void testParseO2() {
// 테스트 케이스 1: floatValue가 유효한 범위인 경우
double o2_1 = SensorDataParser.parseO2(20.9, 0);
assertEquals(20.9, o2_1, 0.1, "O2 floatValue 테스트 실패");
// 테스트 케이스 2: unsignedInt32Value에서 추출 (상위 16비트)
double o2_2 = SensorDataParser.parseO2(0.0, 2090 << 16); // 2090 / 100 = 20.9%
assertEquals(20.9, o2_2, 0.1, "O2 unsignedInt32Value 테스트 실패");
}
@Test
void testParseCO() {
// 테스트 케이스 1: floatValue가 유효한 범위인 경우
double co1 = SensorDataParser.parseCO(2.5, 0);
assertEquals(2.5, co1, 0.01, "CO floatValue 테스트 실패");
// 테스트 케이스 2: signedInt32Value에서 추출
double co2 = SensorDataParser.parseCO(0.0, 250); // 250 / 100 = 2.5 ppm
assertEquals(2.5, co2, 0.01, "CO signedInt32Value 테스트 실패");
}
@Test
void testEdgeCases() {
// 경계값 테스트
assertDoesNotThrow(() -> {
SensorDataParser.parseTemperature(Double.MAX_VALUE, Double.MAX_VALUE, Integer.MAX_VALUE);
SensorDataParser.parseHumidity(Double.MAX_VALUE);
SensorDataParser.parsePM10(Double.MAX_VALUE, Integer.MAX_VALUE);
SensorDataParser.parsePM25(Double.MAX_VALUE, Integer.MAX_VALUE);
SensorDataParser.parsePressure(Integer.MAX_VALUE);
SensorDataParser.parseIllumination(Double.MAX_VALUE, Long.MAX_VALUE);
SensorDataParser.parseTVOC(Double.MAX_VALUE, Integer.MAX_VALUE);
SensorDataParser.parseCO2(Double.MAX_VALUE, Integer.MAX_VALUE);
SensorDataParser.parseO2(Double.MAX_VALUE, Long.MAX_VALUE);
SensorDataParser.parseCO(Double.MAX_VALUE, Integer.MAX_VALUE);
}, "경계값 테스트에서 예외가 발생했습니다");
}
}
\ No newline at end of file
# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /app
# Install git and ca-certificates
RUN apk add --no-cache git ca-certificates
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/sensor-server
# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy the binary from builder stage
COPY --from=builder /app/main .
# Expose port
EXPOSE 8080
# Run the application
CMD ["./main"]
\ No newline at end of file
package main
import (
"log"
"os"
"os/signal"
"syscall"
"sensor-server/internal/server"
)
func main() {
// 서버 생성
srv := server.NewServer()
// 서버 초기화
if err := srv.Initialize(); err != nil {
log.Fatalf("Failed to initialize server: %v", err)
}
// 서버 시작 (고루틴으로 실행)
go func() {
if err := srv.Run(); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}()
// 종료 신호 대기
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
}
\ No newline at end of file
# Server Configuration
PORT=8080
GIN_MODE=debug
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=password
DB_NAME=sensor_db
# Redis Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# Logging
LOG_LEVEL=info
\ No newline at end of file
module sensor-server
go 1.23
toolchain go1.23.11
require (
github.com/gin-gonic/gin v1.10.1
github.com/go-redis/redis/v8 v8.11.5
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.1
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
package api
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"sensor-server/internal/cache"
"sensor-server/internal/database"
"sensor-server/internal/models"
"sensor-server/internal/websocket"
)
// Handler API 핸들러 구조체
type Handler struct {
db *gorm.DB
hub *websocket.Hub
}
// NewHandler 새로운 핸들러 생성
func NewHandler(hub *websocket.Hub) *Handler {
return &Handler{
db: database.GetDB(),
hub: hub,
}
}
// HealthCheck 헬스체크 엔드포인트
func (h *Handler) HealthCheck(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"timestamp": time.Now(),
"service": "sensor-server",
})
}
// ReceiveSensorData 센서 데이터 수신
func (h *Handler) ReceiveSensorData(c *gin.Context) {
var request models.SensorDataRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid request data: " + err.Error(),
})
return
}
// recorded_time 문자열을 time.Time으로 파싱
recordedTime, err := time.Parse(time.RFC3339, request.RecordedTime)
if err != nil {
// ISO_LOCAL_DATE_TIME 형식도 시도
recordedTime, err = time.Parse("2006-01-02T15:04:05", request.RecordedTime)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid recorded_time format: " + err.Error(),
})
return
}
}
// 센서 데이터 생성
reading := &models.SensorReading{
DeviceID: request.DeviceID,
NodeID: request.NodeID,
Temperature: request.Temperature,
Humidity: request.Humidity,
Longitude: request.Longitude,
Latitude: request.Latitude,
// 추가 센서 데이터 필드
FloatValue: request.FloatValue,
SignedInt32Value: request.SignedInt32Value,
UnsignedInt32Value: request.UnsignedInt32Value,
// 원시 데이터 (디버깅용)
RawTem: request.RawTem,
RawHum: request.RawHum,
RecordedTime: recordedTime,
ReceivedTime: time.Now(),
}
// 데이터베이스에 저장
if err := h.db.Create(reading).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to save sensor data: " + err.Error(),
})
return
}
// Redis 캐시 업데이트
if err := cache.CacheLatestReading(request.DeviceID, reading); err != nil {
// 캐시 실패는 로그만 남기고 계속 진행
c.Error(err)
}
// WebSocket 브로드캐스트
h.hub.BroadcastSensorData(reading)
// 디바이스 정보 업데이트
h.updateDeviceInfo(request.DeviceID)
c.JSON(http.StatusOK, models.SensorDataResponse{
Success: true,
Message: "Sensor data received successfully",
Data: reading,
})
}
// ReceiveExtendedSensorData 확장된 센서 데이터 수신
func (h *Handler) ReceiveExtendedSensorData(c *gin.Context) {
var request models.ExtendedSensorDataRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid request data: " + err.Error(),
})
return
}
// recorded_time 문자열을 time.Time으로 파싱
recordedTime, err := time.Parse(time.RFC3339, request.RecordedTime)
if err != nil {
// ISO_LOCAL_DATE_TIME 형식도 시도
recordedTime, err = time.Parse("2006-01-02T15:04:05", request.RecordedTime)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid recorded_time format: " + err.Error(),
})
return
}
}
// 확장된 센서 데이터 생성
reading := &models.SensorReading{
DeviceID: request.DeviceID,
NodeID: request.NodeID,
Temperature: request.Temperature,
Humidity: request.Humidity,
Longitude: request.Longitude,
Latitude: request.Latitude,
// 추가 센서 데이터 필드
FloatValue: request.FloatValue,
SignedInt32Value: request.SignedInt32Value,
UnsignedInt32Value: request.UnsignedInt32Value,
// 원시 데이터 (디버깅용)
RawTem: request.RawTem,
RawHum: request.RawHum,
// 환경 센서 데이터
PM10: request.PM10,
PM25: request.PM25,
Pressure: request.Pressure,
Illumination: request.Illumination,
TVOC: request.TVOC,
CO2: request.CO2,
O2: request.O2,
CO: request.CO,
RecordedTime: recordedTime,
ReceivedTime: time.Now(),
}
// 데이터베이스에 저장
if err := h.db.Create(reading).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to save extended sensor data: " + err.Error(),
})
return
}
// Redis 캐시 업데이트
if err := cache.CacheLatestReading(request.DeviceID, reading); err != nil {
// 캐시 실패는 로그만 남기고 계속 진행
c.Error(err)
}
// WebSocket 브로드캐스트
h.hub.BroadcastSensorData(reading)
// 디바이스 정보 업데이트
h.updateDeviceInfo(request.DeviceID)
c.JSON(http.StatusOK, models.SensorDataResponse{
Success: true,
Message: "Extended sensor data received successfully",
Data: reading,
})
}
// GetLatestData 최신 센서 데이터 조회
func (h *Handler) GetLatestData(c *gin.Context) {
deviceID := c.Param("deviceId")
if deviceID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Device ID is required",
})
return
}
// 먼저 캐시에서 조회
reading, err := cache.GetLatestReading(deviceID)
if err == nil {
c.JSON(http.StatusOK, models.SensorDataResponse{
Success: true,
Data: reading,
})
return
}
// 캐시에 없으면 데이터베이스에서 조회
var latestReading models.SensorReading
if err := h.db.Where("device_id = ?", deviceID).
Order("recorded_time DESC").
First(&latestReading).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "No data found for device: " + deviceID,
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Database error: " + err.Error(),
})
return
}
// 캐시에 저장
cache.CacheLatestReading(deviceID, &latestReading)
c.JSON(http.StatusOK, models.SensorDataResponse{
Success: true,
Data: &latestReading,
})
}
// GetHistory 히스토리 데이터 조회
func (h *Handler) GetHistory(c *gin.Context) {
deviceID := c.Param("deviceId")
if deviceID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Device ID is required",
})
return
}
// 쿼리 파라미터 파싱
limitStr := c.DefaultQuery("limit", "100")
offsetStr := c.DefaultQuery("offset", "0")
startTime := c.Query("start_time")
endTime := c.Query("end_time")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 || limit > 1000 {
limit = 100
}
offset, err := strconv.Atoi(offsetStr)
if err != nil || offset < 0 {
offset = 0
}
// 쿼리 빌드
query := h.db.Where("device_id = ?", deviceID)
if startTime != "" {
if start, err := time.Parse(time.RFC3339, startTime); err == nil {
query = query.Where("recorded_time >= ?", start)
}
}
if endTime != "" {
if end, err := time.Parse(time.RFC3339, endTime); err == nil {
query = query.Where("recorded_time <= ?", end)
}
}
// 총 개수 조회
var total int64
query.Model(&models.SensorReading{}).Count(&total)
// 데이터 조회
var readings []models.SensorReading
if err := query.Order("recorded_time DESC").
Limit(limit).
Offset(offset).
Find(&readings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Database error: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, models.HistoryResponse{
Success: true,
Data: readings,
Total: total,
})
}
// GetDevices 디바이스 목록 조회
func (h *Handler) GetDevices(c *gin.Context) {
var devices []models.Device
if err := h.db.Find(&devices).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Database error: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, models.DeviceListResponse{
Success: true,
Devices: devices,
})
}
// updateDeviceInfo 디바이스 정보 업데이트
func (h *Handler) updateDeviceInfo(deviceID string) {
var device models.Device
if err := h.db.Where("device_id = ?", deviceID).First(&device).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// 새 디바이스 생성
device = models.Device{
DeviceID: deviceID,
Name: "Device " + deviceID,
Status: "active",
LastSeen: time.Now(),
}
h.db.Create(&device)
}
} else {
// 기존 디바이스 업데이트
device.LastSeen = time.Now()
device.Status = "active"
h.db.Save(&device)
}
// 캐시 업데이트
cache.CacheDeviceInfo(&device)
}
\ No newline at end of file
package cache
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"time"
"github.com/go-redis/redis/v8"
"sensor-server/internal/models"
)
var RedisClient *redis.Client
// InitRedis Redis 초기화
func InitRedis() error {
RedisClient = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%s", getEnv("REDIS_HOST", "localhost"), getEnv("REDIS_PORT", "6379")),
Password: getEnv("REDIS_PASSWORD", ""),
DB: 0,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := RedisClient.Ping(ctx).Result()
if err != nil {
return fmt.Errorf("failed to connect to Redis: %v", err)
}
log.Println("Redis initialized successfully")
return nil
}
// GetRedisClient Redis 클라이언트 반환
func GetRedisClient() *redis.Client {
return RedisClient
}
// CacheLatestReading 최신 센서 데이터 캐시
func CacheLatestReading(deviceID string, reading *models.SensorReading) error {
ctx := context.Background()
key := fmt.Sprintf("latest:%s", deviceID)
data, err := json.Marshal(reading)
if err != nil {
return err
}
// 1시간 동안 캐시
return RedisClient.Set(ctx, key, data, time.Hour).Err()
}
// GetLatestReading 캐시된 최신 센서 데이터 조회
func GetLatestReading(deviceID string) (*models.SensorReading, error) {
ctx := context.Background()
key := fmt.Sprintf("latest:%s", deviceID)
data, err := RedisClient.Get(ctx, key).Result()
if err != nil {
return nil, err
}
var reading models.SensorReading
err = json.Unmarshal([]byte(data), &reading)
if err != nil {
return nil, err
}
return &reading, nil
}
// CacheDeviceInfo 디바이스 정보 캐시
func CacheDeviceInfo(device *models.Device) error {
ctx := context.Background()
key := fmt.Sprintf("device:%s", device.DeviceID)
data, err := json.Marshal(device)
if err != nil {
return err
}
// 24시간 동안 캐시
return RedisClient.Set(ctx, key, data, 24*time.Hour).Err()
}
// GetDeviceInfo 캐시된 디바이스 정보 조회
func GetDeviceInfo(deviceID string) (*models.Device, error) {
ctx := context.Background()
key := fmt.Sprintf("device:%s", deviceID)
data, err := RedisClient.Get(ctx, key).Result()
if err != nil {
return nil, err
}
var device models.Device
err = json.Unmarshal([]byte(data), &device)
if err != nil {
return nil, err
}
return &device, nil
}
// BroadcastSensorData WebSocket 브로드캐스트용 데이터 캐시
func BroadcastSensorData(reading *models.SensorReading) error {
ctx := context.Background()
key := "broadcast:sensor_data"
data, err := json.Marshal(reading)
if err != nil {
return err
}
// Redis Pub/Sub을 통해 브로드캐스트
return RedisClient.Publish(ctx, key, data).Err()
}
// getEnv 환경변수 조회 (기본값 포함)
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
\ No newline at end of file
package database
import (
"fmt"
"log"
"os"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"sensor-server/internal/models"
)
var DB *gorm.DB
// InitDatabase 데이터베이스 초기화
func InitDatabase() error {
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
getEnv("DB_HOST", "localhost"),
getEnv("DB_USER", "postgres"),
getEnv("DB_PASSWORD", "password"),
getEnv("DB_NAME", "sensor_db"),
getEnv("DB_PORT", "5432"),
)
var err error
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
return fmt.Errorf("failed to connect to database: %v", err)
}
// 안전한 마이그레이션 수행
err = safeMigrate()
if err != nil {
return fmt.Errorf("failed to migrate database: %v", err)
}
// Connection pool 설정
sqlDB, err := DB.DB()
if err != nil {
return fmt.Errorf("failed to get underlying sql.DB: %v", err)
}
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetConnMaxLifetime(time.Hour)
log.Println("Database initialized successfully")
return nil
}
// safeMigrate 안전한 마이그레이션 수행
func safeMigrate() error {
// 기존 제약조건 확인 및 정리
if err := cleanupConstraints(); err != nil {
log.Printf("Warning: failed to cleanup constraints: %v", err)
}
// AutoMigrate 수행
return DB.AutoMigrate(&models.SensorReading{}, &models.Device{})
}
// cleanupConstraints 기존 제약조건 정리
func cleanupConstraints() error {
// devices 테이블의 device_id unique 제약조건 확인
var count int64
err := DB.Raw(`
SELECT COUNT(*) FROM information_schema.table_constraints
WHERE table_name = 'devices'
AND constraint_name = 'uni_devices_device_id'
`).Scan(&count).Error
if err != nil {
return err
}
// 제약조건이 존재하면 삭제
if count > 0 {
err = DB.Exec(`ALTER TABLE devices DROP CONSTRAINT IF EXISTS uni_devices_device_id`).Error
if err != nil {
return err
}
log.Println("Cleaned up existing constraint: uni_devices_device_id")
}
return nil
}
// GetDB 데이터베이스 인스턴스 반환
func GetDB() *gorm.DB {
return DB
}
// getEnv 환경변수 조회 (기본값 포함)
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built app to nginx
COPY --from=builder /app/build /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
\ No newline at end of file
module.exports = 'test-file-stub';
\ No newline at end of file
This diff is collapsed.
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/__mocks__/fileMock.js',
},
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{ts,tsx}',
'<rootDir>/src/**/*.{test,spec}.{ts,tsx}',
],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/index.tsx',
'!src/reportWebVitals.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
testPathIgnorePatterns: ['/node_modules/', '/build/', '/dist/'],
testEnvironmentOptions: {
url: 'http://localhost'
}
};
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment